quoxcli 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +35 -0
- package/README.md +142 -2
- package/bin/quox.js +292 -0
- package/bin/quoxon.js +8 -0
- package/lib/api/endpoints/admin.js +125 -0
- package/lib/api/endpoints/aee.js +38 -0
- package/lib/api/endpoints/agent-attach.js +126 -0
- package/lib/api/endpoints/agents.js +56 -0
- package/lib/api/endpoints/assistants.js +50 -0
- package/lib/api/endpoints/audit.js +14 -0
- package/lib/api/endpoints/auth.js +50 -0
- package/lib/api/endpoints/awareness.js +34 -0
- package/lib/api/endpoints/backup-schedules.js +20 -0
- package/lib/api/endpoints/backups.js +86 -0
- package/lib/api/endpoints/brain.js +140 -0
- package/lib/api/endpoints/budgets.js +62 -0
- package/lib/api/endpoints/calendarq.js +71 -0
- package/lib/api/endpoints/catalog.js +14 -0
- package/lib/api/endpoints/chat.js +54 -0
- package/lib/api/endpoints/consent.js +26 -0
- package/lib/api/endpoints/contactsq.js +66 -0
- package/lib/api/endpoints/conversations.js +32 -0
- package/lib/api/endpoints/custom-roles.js +92 -0
- package/lib/api/endpoints/divisions.js +56 -0
- package/lib/api/endpoints/domains.js +38 -0
- package/lib/api/endpoints/engagement.js +231 -0
- package/lib/api/endpoints/engine.js +11 -0
- package/lib/api/endpoints/entity.js +50 -0
- package/lib/api/endpoints/executions.js +32 -0
- package/lib/api/endpoints/file.js +20 -0
- package/lib/api/endpoints/files.js +26 -0
- package/lib/api/endpoints/github.js +21 -0
- package/lib/api/endpoints/governance.js +43 -0
- package/lib/api/endpoints/hat.js +36 -0
- package/lib/api/endpoints/inbox.js +32 -0
- package/lib/api/endpoints/inboxq.js +120 -0
- package/lib/api/endpoints/index.js +228 -0
- package/lib/api/endpoints/invites.js +19 -0
- package/lib/api/endpoints/llm-policy.js +43 -0
- package/lib/api/endpoints/lucid.js +89 -0
- package/lib/api/endpoints/members.js +32 -0
- package/lib/api/endpoints/memory.js +75 -0
- package/lib/api/endpoints/mfa.js +32 -0
- package/lib/api/endpoints/mon.js +178 -0
- package/lib/api/endpoints/notesq.js +43 -0
- package/lib/api/endpoints/objectives.js +62 -0
- package/lib/api/endpoints/orgs.js +38 -0
- package/lib/api/endpoints/plugins.js +134 -0
- package/lib/api/endpoints/powers.js +75 -0
- package/lib/api/endpoints/prompts.js +69 -0
- package/lib/api/endpoints/qlarity.js +122 -0
- package/lib/api/endpoints/quoxbox.js +55 -0
- package/lib/api/endpoints/quoxchat.js +54 -0
- package/lib/api/endpoints/quoxflow.js +32 -0
- package/lib/api/endpoints/quoxplan.js +114 -0
- package/lib/api/endpoints/quoxseo.js +108 -0
- package/lib/api/endpoints/reach.js +47 -0
- package/lib/api/endpoints/runs.js +38 -0
- package/lib/api/endpoints/safety.js +46 -0
- package/lib/api/endpoints/scim.js +46 -0
- package/lib/api/endpoints/scopes.js +14 -0
- package/lib/api/endpoints/security.js +69 -0
- package/lib/api/endpoints/setup.js +90 -0
- package/lib/api/endpoints/skills.js +180 -0
- package/lib/api/endpoints/sso.js +67 -0
- package/lib/api/endpoints/tags.js +32 -0
- package/lib/api/endpoints/teams.js +56 -0
- package/lib/api/endpoints/telephony.js +46 -0
- package/lib/api/endpoints/tg.js +56 -0
- package/lib/api/endpoints/tools.js +50 -0
- package/lib/api/endpoints/traces.js +157 -0
- package/lib/api/endpoints/triggers.js +70 -0
- package/lib/api/endpoints/user-library.js +41 -0
- package/lib/api/endpoints/users.js +20 -0
- package/lib/api/endpoints/v1.js +499 -0
- package/lib/api/endpoints/vault.js +192 -0
- package/lib/api/endpoints/voice.js +22 -0
- package/lib/api/endpoints/volt.js +84 -0
- package/lib/api/endpoints/ward.js +40 -0
- package/lib/api/endpoints/webhooks.js +46 -0
- package/lib/api/endpoints/workflows.js +162 -0
- package/lib/api/endpoints/x.js +24 -0
- package/lib/api/endpoints.js +15 -0
- package/lib/commands/admin.js +359 -0
- package/lib/commands/aee.js +99 -0
- package/lib/commands/agent.js +201 -0
- package/lib/commands/agentAttach.js +1440 -0
- package/lib/commands/approvals.js +47 -0
- package/lib/commands/assistant.js +114 -0
- package/lib/commands/auth.js +458 -0
- package/lib/commands/awareness.js +88 -0
- package/lib/commands/backup.js +373 -0
- package/lib/commands/brain.js +994 -0
- package/lib/commands/budget.js +241 -0
- package/lib/commands/build.js +156 -0
- package/lib/commands/calendar.js +216 -0
- package/lib/commands/capabilities.js +34 -0
- package/lib/commands/chat.js +240 -0
- package/lib/commands/claude.js +354 -0
- package/lib/commands/config-cmd.js +106 -0
- package/lib/commands/consent.js +84 -0
- package/lib/commands/contacts.js +967 -0
- package/lib/commands/contactsSelftest.js +132 -0
- package/lib/commands/conversation.js +74 -0
- package/lib/commands/department.js +75 -0
- package/lib/commands/dev.js +620 -0
- package/lib/commands/devResident.js +314 -0
- package/lib/commands/devSupervisor.js +1050 -0
- package/lib/commands/discord.js +96 -0
- package/lib/commands/doctor.js +181 -0
- package/lib/commands/domain.js +114 -0
- package/lib/commands/done.js +277 -0
- package/lib/commands/engagement.js +586 -0
- package/lib/commands/entity.js +119 -0
- package/lib/commands/facebook.js +212 -0
- package/lib/commands/file.js +128 -0
- package/lib/commands/fleet.js +882 -0
- package/lib/commands/governance.js +638 -0
- package/lib/commands/gripe.js +324 -0
- package/lib/commands/hat.js +205 -0
- package/lib/commands/helpers.js +79 -0
- package/lib/commands/inbox.js +122 -0
- package/lib/commands/instagram.js +264 -0
- package/lib/commands/integration.js +100 -0
- package/lib/commands/inventory.js +115 -0
- package/lib/commands/invites.js +45 -0
- package/lib/commands/licence.js +280 -0
- package/lib/commands/linkedin.js +178 -0
- package/lib/commands/llm-policy.js +117 -0
- package/lib/commands/llm.js +45 -0
- package/lib/commands/lucid.js +591 -0
- package/lib/commands/mail.js +531 -0
- package/lib/commands/memory.js +284 -0
- package/lib/commands/mon.js +514 -0
- package/lib/commands/monitoring.js +86 -0
- package/lib/commands/motion.js +22 -0
- package/lib/commands/notes.js +1246 -0
- package/lib/commands/notesBatch.js +160 -0
- package/lib/commands/notesBridges.js +56 -0
- package/lib/commands/notesLinks.js +60 -0
- package/lib/commands/notification.js +57 -0
- package/lib/commands/nucleus.js +92 -0
- package/lib/commands/objective.js +229 -0
- package/lib/commands/onboard.js +328 -0
- package/lib/commands/open.js +56 -0
- package/lib/commands/org.js +266 -0
- package/lib/commands/plan.js +906 -0
- package/lib/commands/plugin-doctor.js +1091 -0
- package/lib/commands/plugin.js +539 -0
- package/lib/commands/policy.js +78 -0
- package/lib/commands/powers.js +296 -0
- package/lib/commands/prompt.js +350 -0
- package/lib/commands/qlarity.js +690 -0
- package/lib/commands/quoxbox.js +270 -0
- package/lib/commands/quoxchat.js +266 -0
- package/lib/commands/quoxseo.js +340 -0
- package/lib/commands/reach.js +158 -0
- package/lib/commands/role.js +286 -0
- package/lib/commands/run.js +132 -0
- package/lib/commands/safety.js +107 -0
- package/lib/commands/scim.js +88 -0
- package/lib/commands/scope.js +58 -0
- package/lib/commands/security.js +157 -0
- package/lib/commands/service.js +89 -0
- package/lib/commands/settings.js +99 -0
- package/lib/commands/setup.js +543 -0
- package/lib/commands/skillManifest.js +113 -0
- package/lib/commands/skillRightsFormat.js +38 -0
- package/lib/commands/skills.js +644 -0
- package/lib/commands/slot.js +1198 -0
- package/lib/commands/sso.js +229 -0
- package/lib/commands/status.js +137 -0
- package/lib/commands/tag.js +91 -0
- package/lib/commands/team.js +140 -0
- package/lib/commands/tg.js +240 -0
- package/lib/commands/tiktok.js +218 -0
- package/lib/commands/tool.js +216 -0
- package/lib/commands/trace.js +525 -0
- package/lib/commands/trigger.js +128 -0
- package/lib/commands/vault.js +1424 -0
- package/lib/commands/voice.js +182 -0
- package/lib/commands/volt.js +528 -0
- package/lib/commands/watch.js +115 -0
- package/lib/commands/webhook.js +137 -0
- package/lib/commands/workflow.js +503 -0
- package/lib/commands/x.js +439 -0
- package/lib/core/actingAs.js +136 -0
- package/lib/core/aee.js +53 -0
- package/lib/core/agentAlias.js +163 -0
- package/lib/core/approvalContext.js +61 -0
- package/lib/core/auth.js +350 -0
- package/lib/core/banner.js +143 -0
- package/lib/core/brainPrime.js +90 -0
- package/lib/core/callerSource.js +129 -0
- package/lib/core/claudeLs.js +94 -0
- package/lib/core/client.js +584 -0
- package/lib/core/completions.js +163 -0
- package/lib/core/config.js +551 -0
- package/lib/core/confirm.js +37 -0
- package/lib/core/deeplinks.js +76 -0
- package/lib/core/doneEngineFetch.js +219 -0
- package/lib/core/errors.js +132 -0
- package/lib/core/global-flags.js +90 -0
- package/lib/core/governedSession.js +402 -0
- package/lib/core/lazyCommands.js +119 -0
- package/lib/core/liveSessionsReport.js +112 -0
- package/lib/core/org-override.js +52 -0
- package/lib/core/output.js +239 -0
- package/lib/core/prodEnv.js +7 -0
- package/lib/core/quoxglyph.js +158 -0
- package/lib/core/residentAlarm.js +282 -0
- package/lib/core/residentAuth.js +243 -0
- package/lib/core/residentPump.js +229 -0
- package/lib/core/residentSeed.js +104 -0
- package/lib/core/sessionHeartbeat.js +192 -0
- package/lib/core/sessionsOverview.js +160 -0
- package/lib/core/slotKeeper.js +282 -0
- package/lib/core/supervisorEngine.js +864 -0
- package/lib/core/systemdInstaller.js +256 -0
- package/lib/core/version.js +87 -0
- package/lib/core/websiteSession.js +148 -0
- package/lib/lucid/LucidIntro.jsx +105 -0
- package/lib/lucid/blind.js +71 -0
- package/lib/lucid/deck.js +32 -0
- package/lib/lucid/fmt.js +139 -0
- package/lib/lucid/sitrep.js +173 -0
- package/lib/plugins/loader.js +93 -0
- package/lib/quoxon/activity.js +104 -0
- package/lib/quoxon/api.js +473 -0
- package/lib/quoxon/app.js +1262 -0
- package/lib/quoxon/capabilities.js +75 -0
- package/lib/quoxon/commands.js +154 -0
- package/lib/quoxon/connect.js +467 -0
- package/lib/quoxon/connectCard.js +28 -0
- package/lib/quoxon/context.js +68 -0
- package/lib/quoxon/copy.js +33 -0
- package/lib/quoxon/demo.fixtures.js +24 -0
- package/lib/quoxon/demo.js +35 -0
- package/lib/quoxon/egg.js +36 -0
- package/lib/quoxon/events.js +458 -0
- package/lib/quoxon/gallery.js +223 -0
- package/lib/quoxon/indicator.js +193 -0
- package/lib/quoxon/intent.js +41 -0
- package/lib/quoxon/keys.js +121 -0
- package/lib/quoxon/main.js +244 -0
- package/lib/quoxon/markdown.js +317 -0
- package/lib/quoxon/model.js +205 -0
- package/lib/quoxon/motion/clock.js +69 -0
- package/lib/quoxon/motion/color.js +35 -0
- package/lib/quoxon/motion/frame.js +14 -0
- package/lib/quoxon/motion/reveal.js +54 -0
- package/lib/quoxon/motion/ring.js +79 -0
- package/lib/quoxon/objects/agent.js +61 -0
- package/lib/quoxon/objects/crystal.js +100 -0
- package/lib/quoxon/objects/fetch.js +170 -0
- package/lib/quoxon/objects/index.js +85 -0
- package/lib/quoxon/objects/ladder.js +71 -0
- package/lib/quoxon/objects/memory.js +44 -0
- package/lib/quoxon/objects/model.js +118 -0
- package/lib/quoxon/objects/proof.js +110 -0
- package/lib/quoxon/objects/skill.js +105 -0
- package/lib/quoxon/objects/view.js +76 -0
- package/lib/quoxon/objects/workflow.js +119 -0
- package/lib/quoxon/observers.js +42 -0
- package/lib/quoxon/onboarding/copy.js +235 -0
- package/lib/quoxon/onboarding/credential.js +95 -0
- package/lib/quoxon/onboarding/detect.js +22 -0
- package/lib/quoxon/onboarding/finish.js +119 -0
- package/lib/quoxon/onboarding/index.js +67 -0
- package/lib/quoxon/onboarding/installRunner.js +225 -0
- package/lib/quoxon/onboarding/intent.js +53 -0
- package/lib/quoxon/onboarding/journey.js +73 -0
- package/lib/quoxon/onboarding/preflight.js +142 -0
- package/lib/quoxon/onboarding/runner.js +58 -0
- package/lib/quoxon/onboarding/view.js +625 -0
- package/lib/quoxon/print.js +94 -0
- package/lib/quoxon/qart.js +379 -0
- package/lib/quoxon/qglyph.js +143 -0
- package/lib/quoxon/qhello.js +58 -0
- package/lib/quoxon/qmotion.js +111 -0
- package/lib/quoxon/render.js +648 -0
- package/lib/quoxon/roster.js +221 -0
- package/lib/quoxon/route.js +38 -0
- package/lib/quoxon/rules.js +130 -0
- package/lib/quoxon/session.js +68 -0
- package/lib/quoxon/skills.js +30 -0
- package/lib/quoxon/slash/compact.js +90 -0
- package/lib/quoxon/slash/ledger.js +83 -0
- package/lib/quoxon/slash/listView.js +28 -0
- package/lib/quoxon/slash/palette.js +104 -0
- package/lib/quoxon/slash/paletteController.js +59 -0
- package/lib/quoxon/slash/panels.js +101 -0
- package/lib/quoxon/slash/picker.js +38 -0
- package/lib/quoxon/slash/restart.js +144 -0
- package/lib/quoxon/slash/resume.js +59 -0
- package/lib/quoxon/slash/settings.js +50 -0
- package/lib/quoxon/sound/index.js +186 -0
- package/lib/quoxon/sound/mapping.js +119 -0
- package/lib/quoxon/sound/output.js +52 -0
- package/lib/quoxon/sound/synth.js +122 -0
- package/lib/quoxon/term.js +106 -0
- package/lib/quoxon/textBuffer.js +242 -0
- package/lib/quoxon/theme.js +253 -0
- package/lib/quoxon/transcript.js +150 -0
- package/lib/quoxon/web/qface.js +81 -0
- package/lib/quoxon/welcome.js +52 -0
- package/lib/tui/app.jsx +289 -0
- package/lib/tui/components/Breadcrumb.jsx +42 -0
- package/lib/tui/components/ChatInput.jsx +68 -0
- package/lib/tui/components/Confirm.jsx +32 -0
- package/lib/tui/components/DataTable.jsx +270 -0
- package/lib/tui/components/DetailPanel.jsx +241 -0
- package/lib/tui/components/HelpOverlay.jsx +67 -0
- package/lib/tui/components/Layout.jsx +129 -0
- package/lib/tui/components/Palette.jsx +141 -0
- package/lib/tui/components/SearchInput.jsx +125 -0
- package/lib/tui/components/Sparkline.jsx +55 -0
- package/lib/tui/components/StatusBar.jsx +133 -0
- package/lib/tui/components/Toast.jsx +52 -0
- package/lib/tui/hooks/useApi.js +44 -0
- package/lib/tui/hooks/useHealth.js +90 -0
- package/lib/tui/hooks/usePolledApi.js +86 -0
- package/lib/tui/hooks/useTerminalSize.js +46 -0
- package/lib/tui/index.js +56 -0
- package/lib/tui/intro/Intro.jsx +332 -0
- package/lib/tui/jsx-loader.js +32 -0
- package/lib/tui/keys.js +87 -0
- package/lib/tui/registry.js +49 -0
- package/lib/tui/theme.js +103 -0
- package/lib/tui/views/AdminView.jsx +398 -0
- package/lib/tui/views/AgentView.jsx +204 -0
- package/lib/tui/views/BackupView.jsx +225 -0
- package/lib/tui/views/BrainView.jsx +223 -0
- package/lib/tui/views/ChatView.jsx +468 -0
- package/lib/tui/views/FilesView.jsx +218 -0
- package/lib/tui/views/FleetView.jsx +234 -0
- package/lib/tui/views/GovernanceView.jsx +453 -0
- package/lib/tui/views/HealthView.jsx +216 -0
- package/lib/tui/views/InboxView.jsx +228 -0
- package/lib/tui/views/LucidDeck.jsx +727 -0
- package/lib/tui/views/MainMenu.jsx +123 -0
- package/lib/tui/views/MemoryView.jsx +377 -0
- package/lib/tui/views/ObjectivesView.jsx +230 -0
- package/lib/tui/views/OrgView.jsx +229 -0
- package/lib/tui/views/ToolView.jsx +221 -0
- package/lib/tui/views/VaultView.jsx +422 -0
- package/lib/tui/views/WorkflowView.jsx +329 -0
- package/package.json +72 -4
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QUOXON J1: the `quox claude` wrapper's session heartbeat. The wrapper starts
|
|
3
|
+
* ONE detached `quox sessions heartbeat <tmux-session> --loop` per tmux session
|
|
4
|
+
* it launches; that child beats POST /api/v1/sessions/heartbeat every interval
|
|
5
|
+
* while the tmux session exists, then calls /api/v1/sessions/end and exits.
|
|
6
|
+
* The server expires a row after 3 missed beats, so a killed child or a
|
|
7
|
+
* powered-off box drops out on its own.
|
|
8
|
+
*
|
|
9
|
+
* Loop hygiene (house rule: failure accounting, backoff, retirement):
|
|
10
|
+
* - a failed beat is counted and logged, never treated as success;
|
|
11
|
+
* - consecutive failures back off exponentially, capped at maxBackoffS;
|
|
12
|
+
* - the loop retires after maxFailures consecutive failures, when the tmux
|
|
13
|
+
* session ends, or when it never appears within the start grace;
|
|
14
|
+
* - a pidfile lock keeps one heartbeat per session across reattaches, and is
|
|
15
|
+
* released on every exit path.
|
|
16
|
+
* Pure parts take injected tmux output / held slots / attach lookup.
|
|
17
|
+
*/
|
|
18
|
+
import { spawn as realSpawn, spawnSync as realSpawnSync } from 'node:child_process';
|
|
19
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, statSync, appendFileSync, truncateSync } from 'node:fs';
|
|
20
|
+
import { homedir } from 'node:os';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
import { slotsBySession, readAttachSafe, readHeldSlotsSafe } from './liveSessionsReport.js';
|
|
23
|
+
|
|
24
|
+
export const PROBE_FORMAT = '#{session_name}\t#{session_created}\t#{session_attached}\t#{session_activity}\t#{pane_pid}\t#{pane_current_command}';
|
|
25
|
+
export const DEFAULT_INTERVAL_S = 60;
|
|
26
|
+
const HB_DIR = () => join(homedir(), '.quox', 'heartbeat');
|
|
27
|
+
const LOG_CAP_BYTES = 256 * 1024;
|
|
28
|
+
|
|
29
|
+
const isoSec = (v) => {
|
|
30
|
+
const n = Number(v);
|
|
31
|
+
return Number.isFinite(n) && n > 0 ? new Date(n * 1000).toISOString() : null;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** One line of PROBE_FORMAT output -> probe record, or null. */
|
|
35
|
+
export function parseProbe(out) {
|
|
36
|
+
const first = String(out || '').split('\n').find((l) => l.trim());
|
|
37
|
+
if (!first) return null;
|
|
38
|
+
const c = first.split('\t');
|
|
39
|
+
if (c.length < 6 || !c[0]) return null;
|
|
40
|
+
const pid = Number.parseInt(c[4], 10);
|
|
41
|
+
return {
|
|
42
|
+
name: c[0],
|
|
43
|
+
started_at: isoSec(c[1]),
|
|
44
|
+
attached: Number(c[2]) > 0,
|
|
45
|
+
last_active: isoSec(c[3]),
|
|
46
|
+
pid: Number.isInteger(pid) ? pid : null,
|
|
47
|
+
command: c[5] || null,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The wrapped tmux session's bare name, as the held-slots ledger records it. */
|
|
52
|
+
const bareOf = (name) => String(name).replace(/^quox-/, '');
|
|
53
|
+
|
|
54
|
+
export function buildBeat({ probe, backend = null, agent = null, slot = null, held = {}, attachLookup = readAttachSafe, intervalS = DEFAULT_INTERVAL_S }) {
|
|
55
|
+
const by = slotsBySession(held);
|
|
56
|
+
const bare = bareOf(probe.name);
|
|
57
|
+
const hit = by[probe.name] || by[bare] || by[bare.replace(/^(codex-|gemini-|tui-)/, '')] || null;
|
|
58
|
+
// The pane runs claude directly, so its pid IS the attach-kit pid; the slot entry's pid is the fallback.
|
|
59
|
+
const att = (probe.pid != null && attachLookup(probe.pid)) || (hit && hit.pid != null && attachLookup(hit.pid)) || null;
|
|
60
|
+
return {
|
|
61
|
+
name: probe.name,
|
|
62
|
+
program: backend || probe.command || null,
|
|
63
|
+
pid: probe.pid,
|
|
64
|
+
started_at: probe.started_at,
|
|
65
|
+
last_active: probe.last_active,
|
|
66
|
+
attached: probe.attached,
|
|
67
|
+
slot: (hit && hit.slot) || slot || null,
|
|
68
|
+
agent: (att && att.agent) || agent || null,
|
|
69
|
+
interval_s: intervalS,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Live probe of one tmux session (exact match). null when it does not exist. */
|
|
74
|
+
export function probeTmux(name, spawnSync = realSpawnSync) {
|
|
75
|
+
const r = spawnSync('tmux', ['display-message', '-p', '-t', `=${name}`, PROBE_FORMAT], { encoding: 'utf8' });
|
|
76
|
+
if (!r || r.error || r.status !== 0) return null;
|
|
77
|
+
const p = parseProbe(r.stdout);
|
|
78
|
+
return p && p.name === name ? p : null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function runHeartbeatLoop({
|
|
82
|
+
tmuxName, backend = null, agent = null, slot = null, intervalS = DEFAULT_INTERVAL_S,
|
|
83
|
+
probe = (n) => probeTmux(n), post, end, held = () => readHeldSlotsSafe(), attachLookup = readAttachSafe,
|
|
84
|
+
sleep = (ms) => new Promise((r) => setTimeout(r, ms)), log = () => {},
|
|
85
|
+
maxFailures = 30, maxBackoffS = 600, graceMs = 30_000, gracePollMs = 2000, maxTicks = Infinity,
|
|
86
|
+
lock = { acquire: () => true, release: () => {} },
|
|
87
|
+
}) {
|
|
88
|
+
if (!lock.acquire()) {
|
|
89
|
+
log(`another heartbeat already holds ${tmuxName}; exiting`);
|
|
90
|
+
return { exit: 'duplicate', beats: 0, failures: 0 };
|
|
91
|
+
}
|
|
92
|
+
let beats = 0;
|
|
93
|
+
let failures = 0;
|
|
94
|
+
let consecutive = 0;
|
|
95
|
+
let ticks = 0;
|
|
96
|
+
let seen = false;
|
|
97
|
+
let waited = 0;
|
|
98
|
+
try {
|
|
99
|
+
while (ticks < maxTicks) {
|
|
100
|
+
const p = probe(tmuxName);
|
|
101
|
+
if (!p) {
|
|
102
|
+
if (!seen && waited < graceMs) { waited += gracePollMs; await sleep(gracePollMs); continue; }
|
|
103
|
+
if (!seen) { log(`${tmuxName} never appeared; exiting`); return { exit: 'never_started', beats, failures }; }
|
|
104
|
+
try { await end(); } catch (e) { log(`end failed: ${e && e.status ? `HTTP ${e.status} ` : ''}${e && e.message}`); }
|
|
105
|
+
log(`${tmuxName} ended after ${beats} beats (${failures} failed)`);
|
|
106
|
+
return { exit: 'ended', beats, failures };
|
|
107
|
+
}
|
|
108
|
+
seen = true;
|
|
109
|
+
ticks += 1;
|
|
110
|
+
let beat;
|
|
111
|
+
try { beat = buildBeat({ probe: p, backend, agent, slot, held: held(), attachLookup, intervalS }); } catch (e) { beat = null; log(`beat build failed: ${e.message}`); }
|
|
112
|
+
try {
|
|
113
|
+
if (!beat) throw new Error('no beat');
|
|
114
|
+
await post(beat);
|
|
115
|
+
beats += 1;
|
|
116
|
+
consecutive = 0;
|
|
117
|
+
} catch (e) {
|
|
118
|
+
failures += 1;
|
|
119
|
+
consecutive += 1;
|
|
120
|
+
log(`beat failed (${consecutive} in a row, ${failures} total): ${e && e.status ? `HTTP ${e.status} ` : ''}${e && e.message}`);
|
|
121
|
+
if (consecutive >= maxFailures) {
|
|
122
|
+
log(`retiring after ${consecutive} consecutive failures`);
|
|
123
|
+
return { exit: 'retired', beats, failures };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const delayS = consecutive ? Math.min(intervalS * 2 ** Math.min(consecutive, 10), maxBackoffS) : intervalS;
|
|
127
|
+
await sleep(delayS * 1000);
|
|
128
|
+
}
|
|
129
|
+
return { exit: 'max_ticks', beats, failures };
|
|
130
|
+
} finally {
|
|
131
|
+
lock.release();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const safeFile = (name) => String(name).replace(/[^A-Za-z0-9._-]/g, '_');
|
|
136
|
+
|
|
137
|
+
/** Pidfile lock under ~/.quox/heartbeat/<session>.pid; a dead holder's file is taken over. */
|
|
138
|
+
export function pidLock(name, { dir = HB_DIR(), pid = process.pid, alive = isAlive } = {}) {
|
|
139
|
+
const file = join(dir, `${safeFile(name)}.pid`);
|
|
140
|
+
return {
|
|
141
|
+
acquire() {
|
|
142
|
+
try {
|
|
143
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
144
|
+
if (existsSync(file)) {
|
|
145
|
+
const other = Number.parseInt(readFileSync(file, 'utf8'), 10);
|
|
146
|
+
if (Number.isInteger(other) && other !== pid && alive(other)) return false;
|
|
147
|
+
}
|
|
148
|
+
writeFileSync(file, String(pid), { mode: 0o600 });
|
|
149
|
+
return true;
|
|
150
|
+
} catch { return false; }
|
|
151
|
+
},
|
|
152
|
+
release() {
|
|
153
|
+
try {
|
|
154
|
+
if (existsSync(file) && Number.parseInt(readFileSync(file, 'utf8'), 10) === pid) unlinkSync(file);
|
|
155
|
+
} catch { /* best effort */ }
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function isAlive(pid) {
|
|
161
|
+
try { process.kill(pid, 0); return true; } catch (e) { return e && e.code === 'EPERM'; }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Append-only per-session log, truncated when it passes LOG_CAP_BYTES. */
|
|
165
|
+
export function fileLogger(name, dir = HB_DIR()) {
|
|
166
|
+
const file = join(dir, `${safeFile(name)}.log`);
|
|
167
|
+
return (msg) => {
|
|
168
|
+
try {
|
|
169
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
170
|
+
if (existsSync(file) && statSync(file).size > LOG_CAP_BYTES) truncateSync(file, 0);
|
|
171
|
+
appendFileSync(file, `${new Date().toISOString()} ${msg}\n`, { mode: 0o600 });
|
|
172
|
+
} catch { /* logging must never kill the loop */ }
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Start the detached heartbeat child for a tmux session the wrapper launched.
|
|
178
|
+
* The child is this same quox binary; it survives the wrapper exiting on detach.
|
|
179
|
+
*/
|
|
180
|
+
export function startHeartbeat({ tmuxSession, backend, agent, slot }, { spawn = realSpawn, argv = process.argv, execPath = process.execPath } = {}) {
|
|
181
|
+
if (!argv[1]) return false;
|
|
182
|
+
const args = [argv[1], 'sessions', 'heartbeat', tmuxSession, '--loop'];
|
|
183
|
+
if (backend) args.push('--backend', backend);
|
|
184
|
+
if (agent) args.push('--agent', agent);
|
|
185
|
+
if (slot) args.push('--slot', slot);
|
|
186
|
+
try {
|
|
187
|
+
const child = spawn(execPath, args, { detached: true, stdio: 'ignore' });
|
|
188
|
+
if (child && child.on) child.on('error', () => {});
|
|
189
|
+
if (child && child.unref) child.unref();
|
|
190
|
+
return true;
|
|
191
|
+
} catch { return false; }
|
|
192
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QUOXON J2: `quox sessions` overview of GET /api/v1/sessions, grouped by
|
|
3
|
+
* agent, newest activity first. overviewLines() is plain text + a tone per
|
|
4
|
+
* line, shared by the CLI renderer (ANSI colour) and the quox conversation
|
|
5
|
+
* client's `/sessions all` (which colours by tone itself).
|
|
6
|
+
*/
|
|
7
|
+
import { hostname as osHostname } from 'node:os';
|
|
8
|
+
import { line } from './quoxglyph.js';
|
|
9
|
+
import { formatAge } from './claudeLs.js';
|
|
10
|
+
import { loadNames } from '../quoxon/session.js';
|
|
11
|
+
|
|
12
|
+
export const GLYPH = { working: '◐', waiting: '⏸', resting: '○', failing: '✖' };
|
|
13
|
+
const TONE = { working: 'ok', waiting: 'warn', failing: 'warn', resting: 'dim' };
|
|
14
|
+
const ANSI = { ok: '\x1b[32m', warn: '\x1b[33m', fail: '\x1b[31m', dim: '\x1b[2m', accent: '\x1b[36m', reset: '\x1b[0m' };
|
|
15
|
+
const stripAnsi = (s) => String(s).replace(/\x1b\[[0-9;]*m/g, '');
|
|
16
|
+
|
|
17
|
+
const clip = (v, n) => {
|
|
18
|
+
const a = [...String(v ?? '')];
|
|
19
|
+
return a.length > n ? a.slice(0, n - 1).join('') + '…' : String(v ?? '');
|
|
20
|
+
};
|
|
21
|
+
const pad = (v, n) => clip(v, n).padEnd(n);
|
|
22
|
+
|
|
23
|
+
function kindLabel(r) {
|
|
24
|
+
if (r.kind === 'tmux') return r.backend ? `tmux·${r.backend}` : 'tmux';
|
|
25
|
+
return r.kind;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function hostLabel(r, localHost) {
|
|
29
|
+
if (!r.host) return r.kind === 'tmux' ? '?' : 'server';
|
|
30
|
+
return localHost && r.host === localHost ? 'this host' : r.host;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const RECENT_CONVERSATIONS = 5;
|
|
34
|
+
const KEEP_KINDS = new Set(['tmux', 'slot', 'resident', 'objective', 'loop', 'workflow']);
|
|
35
|
+
|
|
36
|
+
/** One-line title, clipped on a word boundary when one is near the end, never mid-surrogate. */
|
|
37
|
+
export function clipTitle(raw, max = 34) {
|
|
38
|
+
const s = String(raw ?? '').replace(/[\x00-\x1f\x7f]/g, ' ').replace(/\s+/g, ' ').trim();
|
|
39
|
+
const a = [...s];
|
|
40
|
+
if (a.length <= max) return s;
|
|
41
|
+
const cut = a.slice(0, max - 1).join('');
|
|
42
|
+
const sp = cut.lastIndexOf(' ');
|
|
43
|
+
return (sp >= Math.floor(max / 2) ? cut.slice(0, sp) : cut).replace(/[\s,.;:-]+$/, '') + '…';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const timeOf = (r) => { const t = Date.parse(r.last_active); return Number.isFinite(t) ? t : -Infinity; };
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The default view shows what is useful or eventful: every tmux session, slot,
|
|
50
|
+
* resident and running piece of work, every working/waiting conversation, and
|
|
51
|
+
* only the RECENT_CONVERSATIONS most recent other conversations. `all` keeps
|
|
52
|
+
* everything. Conversation names use the local /rename name when known, else
|
|
53
|
+
* the server title. Regrouped by each row's own agent; "no agent" goes last.
|
|
54
|
+
* -> { groups, hiddenConversations, shown }
|
|
55
|
+
*/
|
|
56
|
+
export function prepareIndex(index, { all = false, names = {}, keep = RECENT_CONVERSATIONS } = {}) {
|
|
57
|
+
const rows = (index.groups || []).flatMap((g) => g.rows).map((r) => (r.kind === 'conversation'
|
|
58
|
+
// The hint is this client's own command, so set it here too (correct against an older server's --print hint).
|
|
59
|
+
? { ...r, name: clipTitle((names && names[r.id] && names[r.id].name) || r.name || '(untitled)'), attach_hint: `quox --conversation ${r.id}` }
|
|
60
|
+
: r));
|
|
61
|
+
let hidden = 0;
|
|
62
|
+
let kept = rows;
|
|
63
|
+
if (!all) {
|
|
64
|
+
const quiet = rows.filter((r) => r.kind === 'conversation' && r.state !== 'working' && r.state !== 'waiting').sort((a, b) => timeOf(b) - timeOf(a));
|
|
65
|
+
const keepIds = new Set(quiet.slice(0, keep));
|
|
66
|
+
hidden = quiet.length - keepIds.size;
|
|
67
|
+
kept = rows.filter((r) => KEEP_KINDS.has(r.kind) || r.kind !== 'conversation' || r.state === 'working' || r.state === 'waiting' || keepIds.has(r));
|
|
68
|
+
}
|
|
69
|
+
const by = new Map();
|
|
70
|
+
for (const r of [...kept].sort((a, b) => timeOf(b) - timeOf(a))) {
|
|
71
|
+
const k = r.agent || null;
|
|
72
|
+
if (!by.has(k)) by.set(k, []);
|
|
73
|
+
by.get(k).push(r);
|
|
74
|
+
}
|
|
75
|
+
const groups = [...by.entries()].map(([agent, rs]) => ({ agent, rows: rs }))
|
|
76
|
+
.sort((a, b) => (a.agent === null) - (b.agent === null) || timeOf(b.rows[0]) - timeOf(a.rows[0]));
|
|
77
|
+
return { groups, hiddenConversations: hidden, shown: kept.length };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** -> [{ text, tone, state? }] */
|
|
81
|
+
export function overviewLines(index, { now = Date.now(), localHost = null, all = false, names = {} } = {}) {
|
|
82
|
+
const prepared = prepareIndex(index, { all, names });
|
|
83
|
+
const counts = index.counts || {};
|
|
84
|
+
const total = counts.total || 0;
|
|
85
|
+
const out = [
|
|
86
|
+
{ text: stripAnsi(line('quox', 'sessions', { result: `${total} live` })), tone: 'accent' },
|
|
87
|
+
{ text: `${total} live · ${counts.working || 0} working · ${counts.waiting || 0} waiting · ${counts.resting || 0} resting · ${counts.failing || 0} failing`, tone: 'dim' },
|
|
88
|
+
];
|
|
89
|
+
if (!total) out.push({ text: 'No live sessions in this org.', tone: 'dim' });
|
|
90
|
+
for (const g of prepared.groups) {
|
|
91
|
+
out.push({ text: `${g.agent || 'no agent'} (${g.rows.length})`, tone: 'accent' });
|
|
92
|
+
for (const r of g.rows) {
|
|
93
|
+
const t = Date.parse(r.last_active);
|
|
94
|
+
const age = Number.isFinite(t) ? formatAge((now - t) / 1000) : '-';
|
|
95
|
+
const extra = [r.slot ? `slot ${r.slot}` : null, r.state_reason || null].filter(Boolean).join(', ');
|
|
96
|
+
const text = ` ${GLYPH[r.state] || '?'} ${pad(r.state, 8)} ${pad(r.name, 34)} ${pad(kindLabel(r), 13)} ${pad(hostLabel(r, localHost), 12)} ${pad(age, 5)} ${r.attach_hint || ''}${extra ? ` (${extra})` : ''}`;
|
|
97
|
+
out.push({ text, tone: TONE[r.state] || 'dim', state: r.state });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (prepared.hiddenConversations > 0) {
|
|
101
|
+
out.push({ text: ` +${prepared.hiddenConversations} more conversations (last 24 h) · quox sessions --all`, tone: 'dim' });
|
|
102
|
+
}
|
|
103
|
+
const bad = (index.sources || []).filter((s) => s.state !== 'connected');
|
|
104
|
+
if (bad.length) {
|
|
105
|
+
out.push({ text: `sources: ${bad.map((s) => `${s.id}: ${s.state}${s.note ? ` (${s.note})` : ''}`).join(' · ')}`, tone: 'dim' });
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function renderOverview(index, { now = Date.now(), localHost = osHostname(), color = false, all = false, names = {} } = {}) {
|
|
111
|
+
return overviewLines(index, { now, localHost, all, names }).map((l) => {
|
|
112
|
+
if (!color) return l.text;
|
|
113
|
+
const c = l.state === 'failing' ? ANSI.fail : ANSI[l.tone] || '';
|
|
114
|
+
return `${c}${l.text}${ANSI.reset}`;
|
|
115
|
+
}).join('\n');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const errText = (e) => `${e && e.status ? `HTTP ${e.status}: ` : ''}${(e && e.message) || 'request failed'}`;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Bare `quox sessions`. --json prints the raw index; --watch redraws every 2 s
|
|
122
|
+
* until interrupted. An error is printed with its status (never shown as an
|
|
123
|
+
* empty list) and exits 1; in --watch it is shown and the next refresh retries.
|
|
124
|
+
*/
|
|
125
|
+
export async function runSessionsOverview({ json = false, watch = false, all = false } = {}, over = {}) {
|
|
126
|
+
const d = {
|
|
127
|
+
write: (s) => process.stdout.write(s),
|
|
128
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
129
|
+
now: () => Date.now(),
|
|
130
|
+
localHost: osHostname(),
|
|
131
|
+
color: !!process.stdout.isTTY && !process.env.NO_COLOR,
|
|
132
|
+
iterations: Infinity,
|
|
133
|
+
names: undefined,
|
|
134
|
+
...over,
|
|
135
|
+
};
|
|
136
|
+
const names = () => d.names ?? loadNames();
|
|
137
|
+
if (!d.fetchIndex) throw new Error('fetchIndex required');
|
|
138
|
+
if (!watch) {
|
|
139
|
+
try {
|
|
140
|
+
const idx = await d.fetchIndex();
|
|
141
|
+
d.write(json ? JSON.stringify(idx, null, 2) + '\n' : renderOverview(idx, { now: d.now(), localHost: d.localHost, color: d.color, all, names: names() }) + '\n');
|
|
142
|
+
return 0;
|
|
143
|
+
} catch (e) {
|
|
144
|
+
d.write(`quox sessions: ${errText(e)}\n`);
|
|
145
|
+
return 1;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
for (let i = 0; i < d.iterations; i++) {
|
|
149
|
+
let body;
|
|
150
|
+
try {
|
|
151
|
+
const idx = await d.fetchIndex();
|
|
152
|
+
body = json ? JSON.stringify(idx) : renderOverview(idx, { now: d.now(), localHost: d.localHost, color: d.color, all, names: names() });
|
|
153
|
+
} catch (e) {
|
|
154
|
+
body = `quox sessions: ${errText(e)} (retrying)`;
|
|
155
|
+
}
|
|
156
|
+
d.write(`\x1b[2J\x1b[H${body}\n\n(refreshing every 2 s, Ctrl-C to stop)\n`);
|
|
157
|
+
await d.sleep(2000);
|
|
158
|
+
}
|
|
159
|
+
return 0;
|
|
160
|
+
}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slot hold keeper (SLOTS-PERFECT, quox#662/B).
|
|
3
|
+
*
|
|
4
|
+
* Why: the collector frees a held slot 15 minutes after its last heartbeat,
|
|
5
|
+
* and the only thing heartbeating was a `quox slot wait` the session had to
|
|
6
|
+
* remember to re-arm. A session that forgot lost its hold silently. The
|
|
7
|
+
* keeper is a detached background loop, spawned by `slot checkout`, that
|
|
8
|
+
* heartbeats on a timer and dies with the Claude session it serves.
|
|
9
|
+
*
|
|
10
|
+
* Loop hygiene (house rule): failures are counted distinctly from successes,
|
|
11
|
+
* back off exponentially (30s doubling, cap 5 min) and the keeper retires
|
|
12
|
+
* after a bounded run of consecutive failures (24; 3 for 401). Everything
|
|
13
|
+
* environmental (clock, sleep, probe, signals, fs dir) is injected.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
openSync, closeSync, writeSync, readFileSync, unlinkSync, appendFileSync,
|
|
18
|
+
statSync, writeFileSync, mkdirSync,
|
|
19
|
+
} from 'node:fs';
|
|
20
|
+
import { homedir } from 'node:os';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
import { execFile as execFileCb } from 'node:child_process';
|
|
23
|
+
import { createHash } from 'node:crypto';
|
|
24
|
+
import { promisify } from 'node:util';
|
|
25
|
+
import { QuoxApiError } from './errors.js';
|
|
26
|
+
|
|
27
|
+
export const KEEP_INTERVAL_MS = 5 * 60 * 1000;
|
|
28
|
+
export const BACKOFF_BASE_MS = 30 * 1000;
|
|
29
|
+
export const BACKOFF_CAP_MS = 5 * 60 * 1000;
|
|
30
|
+
// 24 consecutive failures at the 5-min backoff cap rides out ~2h of
|
|
31
|
+
// collector/auth outage; each failure is one cheap HTTP call, so outlasting a
|
|
32
|
+
// long outage beats letting the hold lapse (the collector revives a stale
|
|
33
|
+
// slot for its own holder on the next successful heartbeat).
|
|
34
|
+
export const MAX_FAILS = 24;
|
|
35
|
+
export const MAX_AUTH_FAILS = 3;
|
|
36
|
+
export const LOG_MAX_BYTES = 256 * 1024;
|
|
37
|
+
// QUOX-CLAUDE-3: the control queue is polled far more often than the 5-min
|
|
38
|
+
// heartbeat so `!compact` feels immediate. Poll failures back off (doubling,
|
|
39
|
+
// capped at CONTROL_BACKOFF_CAP_TICKS skipped ticks) and are counted
|
|
40
|
+
// distinctly from heartbeat failures; they never retire the keeper (the
|
|
41
|
+
// heartbeat owns hold-loss).
|
|
42
|
+
export const CONTROL_POLL_MS = 20 * 1000;
|
|
43
|
+
export const CONTROL_BACKOFF_CAP_TICKS = 15;
|
|
44
|
+
export const BUSY_WINDOW_MS = 60 * 1000;
|
|
45
|
+
export const NO_TMUX_RESULT = 'session not under tmux; start it with quox claude';
|
|
46
|
+
|
|
47
|
+
export function keeperDir() {
|
|
48
|
+
return join(homedir(), '.quox', 'keepers');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Real process probe: liveness via kill -0, start ticks via /proc. */
|
|
52
|
+
export function makeProcessProbe(procStartTime) {
|
|
53
|
+
return {
|
|
54
|
+
alive(pid) {
|
|
55
|
+
try { process.kill(pid, 0); return true; } catch (e) { return e && e.code === 'EPERM'; }
|
|
56
|
+
},
|
|
57
|
+
starttime: procStartTime,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Is `pid` the same process it was (alive, and start ticks unchanged)? */
|
|
62
|
+
export function sameProcess(probe, pid, starttime) {
|
|
63
|
+
if (!pid || !probe.alive(pid)) return false;
|
|
64
|
+
return starttime == null || String(probe.starttime(pid)) === String(starttime);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isHoldLost(err) {
|
|
68
|
+
if (!(err instanceof QuoxApiError)) return false;
|
|
69
|
+
// 409: held by someone else or freed. 404: the slot itself is gone.
|
|
70
|
+
if (err.status === 409 || err.status === 404) return true;
|
|
71
|
+
const said = [err.message, err.code, err.body?.error].filter(Boolean).join(' ');
|
|
72
|
+
return err.status === 403 && /not (the current |slot )?holder/i.test(said);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const execFileP = promisify(execFileCb);
|
|
76
|
+
function defaultExecFile(file, args) {
|
|
77
|
+
return execFileP(file, args, { timeout: 10000 });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function runKeeper(opts) {
|
|
81
|
+
const {
|
|
82
|
+
name, session, claude, heartbeat, release, forget, probe,
|
|
83
|
+
sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
84
|
+
rand = Math.random,
|
|
85
|
+
dir = keeperDir(),
|
|
86
|
+
// Remote control (all optional; absent pollControl = heartbeat-only keeper).
|
|
87
|
+
pollControl, ackControl, tmuxPane = null,
|
|
88
|
+
execFile = defaultExecFile,
|
|
89
|
+
now = () => Date.now(),
|
|
90
|
+
installSignals = (cleanup) => {
|
|
91
|
+
const h = () => { cleanup(); process.exit(0); };
|
|
92
|
+
process.once('SIGTERM', h);
|
|
93
|
+
process.once('SIGINT', h);
|
|
94
|
+
},
|
|
95
|
+
} = opts;
|
|
96
|
+
|
|
97
|
+
mkdirSync(dir, { recursive: true });
|
|
98
|
+
const lockPath = join(dir, `${name}.pid`);
|
|
99
|
+
const logPath = join(dir, `${name}.log`);
|
|
100
|
+
const log = (msg) => {
|
|
101
|
+
try {
|
|
102
|
+
let size = 0;
|
|
103
|
+
try { size = statSync(logPath).size; } catch { /* no log yet */ }
|
|
104
|
+
if (size > LOG_MAX_BYTES) writeFileSync(logPath, '');
|
|
105
|
+
appendFileSync(logPath, `${new Date().toISOString()} [${name}] ${msg}\n`);
|
|
106
|
+
} catch { /* logging must never kill the keeper */ }
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// Single instance per slot: O_EXCL lockfile, taken over when its pid is dead.
|
|
110
|
+
let locked = false;
|
|
111
|
+
for (let attempt = 0; attempt < 2 && !locked; attempt += 1) {
|
|
112
|
+
try {
|
|
113
|
+
const fd = openSync(lockPath, 'wx');
|
|
114
|
+
writeSync(fd, String(process.pid));
|
|
115
|
+
closeSync(fd);
|
|
116
|
+
locked = true;
|
|
117
|
+
} catch (e) {
|
|
118
|
+
if (e.code !== 'EEXIST') throw e;
|
|
119
|
+
let other = NaN;
|
|
120
|
+
try { other = parseInt(readFileSync(lockPath, 'utf-8'), 10); } catch { /* raced away */ }
|
|
121
|
+
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' };
|
|
124
|
+
}
|
|
125
|
+
try { unlinkSync(lockPath); } catch { /* raced away */ }
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (!locked) {
|
|
129
|
+
log('could not acquire keeper lock; exiting');
|
|
130
|
+
return { code: 1, reason: 'lock-failed' };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const cleanup = () => {
|
|
134
|
+
try {
|
|
135
|
+
if (parseInt(readFileSync(lockPath, 'utf-8'), 10) === process.pid) unlinkSync(lockPath);
|
|
136
|
+
} catch { /* already gone */ }
|
|
137
|
+
};
|
|
138
|
+
installSignals(cleanup);
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
log(`keeper started session=${session} claude=${claude?.pid}:${claude?.starttime}`);
|
|
142
|
+
let fails = 0;
|
|
143
|
+
let delay = KEEP_INTERVAL_MS * (1 + rand() * 0.2);
|
|
144
|
+
|
|
145
|
+
// Control queue state: ids already executed (never re-run if an ack failed,
|
|
146
|
+
// only re-acked), and distinct failure accounting with tick-skip backoff.
|
|
147
|
+
const handled = new Map();
|
|
148
|
+
const startedAt = now();
|
|
149
|
+
let lastHeartbeatAt = null;
|
|
150
|
+
// Busy/idle: only a hash of the pane is kept (pane text can hold secrets
|
|
151
|
+
// and the status result is posted to Discord). Armed by the first status
|
|
152
|
+
// request, then refreshed on every control tick.
|
|
153
|
+
let armed = false;
|
|
154
|
+
let paneHash = null;
|
|
155
|
+
let paneChangedAt = null;
|
|
156
|
+
const samplePane = async () => {
|
|
157
|
+
if (!tmuxPane) return false;
|
|
158
|
+
try {
|
|
159
|
+
const out = await execFile('tmux', ['capture-pane', '-p', '-t', tmuxPane]);
|
|
160
|
+
const text = typeof out === 'string' ? out : String(out?.stdout ?? '');
|
|
161
|
+
const h = createHash('sha256').update(text).digest('hex');
|
|
162
|
+
if (paneHash !== null && h !== paneHash) paneChangedAt = now();
|
|
163
|
+
paneHash = h;
|
|
164
|
+
return true;
|
|
165
|
+
} catch { return false; }
|
|
166
|
+
};
|
|
167
|
+
let ctlFails = 0;
|
|
168
|
+
let ctlSkip = 0;
|
|
169
|
+
const controlTick = async () => {
|
|
170
|
+
if (ctlSkip > 0) { ctlSkip -= 1; return; }
|
|
171
|
+
if (claude && !sameProcess(probe, claude.pid, claude.starttime)) return;
|
|
172
|
+
try {
|
|
173
|
+
if (armed) await samplePane();
|
|
174
|
+
const rows = (await pollControl()) || [];
|
|
175
|
+
for (const row of rows) {
|
|
176
|
+
let outcome = handled.get(row.id);
|
|
177
|
+
if (!outcome) {
|
|
178
|
+
outcome = await runControlCommand(row);
|
|
179
|
+
handled.set(row.id, outcome);
|
|
180
|
+
log(`control ${row.command} ${row.id} from ${row.requested_via || '?'}: ${outcome.status} (${outcome.result})`);
|
|
181
|
+
}
|
|
182
|
+
await ackControl(row.id, outcome.status, outcome.result);
|
|
183
|
+
handled.delete(row.id);
|
|
184
|
+
}
|
|
185
|
+
if (ctlFails > 0) log(`control poll recovered after ${ctlFails} failure(s)`);
|
|
186
|
+
ctlFails = 0;
|
|
187
|
+
} catch (err) {
|
|
188
|
+
ctlFails += 1;
|
|
189
|
+
ctlSkip = Math.min(2 ** (ctlFails - 1) - 1, CONTROL_BACKOFF_CAP_TICKS);
|
|
190
|
+
log(`control poll FAILED (${ctlFails} consecutive, skipping ${ctlSkip} tick(s)): ${err.status || ''} ${err.message}`);
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
const fmtDur = (sec) => {
|
|
194
|
+
const s = Math.max(0, Math.floor(sec));
|
|
195
|
+
const d = Math.floor(s / 86400); const h = Math.floor((s % 86400) / 3600); const m = Math.floor((s % 3600) / 60);
|
|
196
|
+
return d ? `${d}d${String(h).padStart(2, '0')}h` : `${h}h${String(m).padStart(2, '0')}m`;
|
|
197
|
+
};
|
|
198
|
+
const statusResult = async () => {
|
|
199
|
+
const parts = [`session=${session}`, `slot=${name}`];
|
|
200
|
+
let claudeUp = null;
|
|
201
|
+
if (claude) {
|
|
202
|
+
const alive = sameProcess(probe, claude.pid, claude.starttime);
|
|
203
|
+
if (alive) {
|
|
204
|
+
try {
|
|
205
|
+
const out = await execFile('ps', ['-o', 'etimes=', '-p', String(claude.pid)]);
|
|
206
|
+
const n = parseInt(String(typeof out === 'string' ? out : out?.stdout).trim(), 10);
|
|
207
|
+
if (Number.isFinite(n)) claudeUp = n;
|
|
208
|
+
} catch { /* uptime unknown */ }
|
|
209
|
+
}
|
|
210
|
+
parts.push(`claude pid ${claude.pid} ${alive ? 'alive' : 'gone'}${alive && claudeUp != null ? ` up ${fmtDur(claudeUp)}` : ''}`);
|
|
211
|
+
}
|
|
212
|
+
if (!tmuxPane) {
|
|
213
|
+
parts.push('not under tmux');
|
|
214
|
+
} else {
|
|
215
|
+
parts.push(`pane=${tmuxPane}`);
|
|
216
|
+
const wasArmed = armed;
|
|
217
|
+
armed = true;
|
|
218
|
+
const ok = await samplePane();
|
|
219
|
+
if (ok) {
|
|
220
|
+
if (!wasArmed) parts.push('activity unknown (first check)');
|
|
221
|
+
else parts.push(paneChangedAt !== null && now() - paneChangedAt <= BUSY_WINDOW_MS ? 'busy' : 'idle');
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
parts.push(lastHeartbeatAt === null ? 'last heartbeat none yet' : `last heartbeat ${new Date(lastHeartbeatAt).toISOString()}`);
|
|
225
|
+
parts.push(`keeper up ${fmtDur((now() - startedAt) / 1000)}`);
|
|
226
|
+
return parts.join(', ').slice(0, 500);
|
|
227
|
+
};
|
|
228
|
+
const runControlCommand = async (row) => {
|
|
229
|
+
if (row.command === 'status') return { status: 'done', result: await statusResult() };
|
|
230
|
+
if (row.command !== 'compact') return { status: 'failed', result: `unsupported command ${row.command}` };
|
|
231
|
+
if (!tmuxPane) return { status: 'failed', result: NO_TMUX_RESULT };
|
|
232
|
+
try {
|
|
233
|
+
await execFile('tmux', ['send-keys', '-t', tmuxPane, '/compact', 'Enter']);
|
|
234
|
+
return { status: 'done', result: `sent /compact to ${tmuxPane}` };
|
|
235
|
+
} catch (err) {
|
|
236
|
+
return { status: 'failed', result: `tmux send-keys failed: ${err.message}` };
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
for (;;) {
|
|
241
|
+
let remaining = delay;
|
|
242
|
+
while (pollControl && remaining > CONTROL_POLL_MS) {
|
|
243
|
+
await sleep(CONTROL_POLL_MS);
|
|
244
|
+
remaining -= CONTROL_POLL_MS;
|
|
245
|
+
await controlTick();
|
|
246
|
+
}
|
|
247
|
+
await sleep(remaining);
|
|
248
|
+
if (pollControl) await controlTick();
|
|
249
|
+
|
|
250
|
+
if (claude && !sameProcess(probe, claude.pid, claude.starttime)) {
|
|
251
|
+
log(`claude session gone (pid ${claude.pid}); releasing ${name} and exiting`);
|
|
252
|
+
try { await release(); } catch (e) { log(`release failed: ${e.message}`); }
|
|
253
|
+
return { code: 0, reason: 'session-gone' };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
await heartbeat();
|
|
258
|
+
lastHeartbeatAt = now();
|
|
259
|
+
if (fails > 0) log(`heartbeat recovered after ${fails} failure(s)`);
|
|
260
|
+
fails = 0;
|
|
261
|
+
log('heartbeat ok');
|
|
262
|
+
delay = KEEP_INTERVAL_MS * (1 + rand() * 0.2);
|
|
263
|
+
} catch (err) {
|
|
264
|
+
if (isHoldLost(err)) {
|
|
265
|
+
log(`HOLD LOST: ${name} is no longer held by ${session}; keeper exiting`);
|
|
266
|
+
try { forget(); } catch { /* best effort */ }
|
|
267
|
+
return { code: 0, reason: 'hold-lost' };
|
|
268
|
+
}
|
|
269
|
+
fails += 1;
|
|
270
|
+
const limit = err instanceof QuoxApiError && err.status === 401 ? MAX_AUTH_FAILS : MAX_FAILS;
|
|
271
|
+
log(`heartbeat FAILED (${fails}/${limit}): ${err.status || ''} ${err.message}`);
|
|
272
|
+
if (fails >= limit) {
|
|
273
|
+
log(`RETIRED after ${fails} consecutive failures; hold will lapse`);
|
|
274
|
+
return { code: 1, reason: 'retired' };
|
|
275
|
+
}
|
|
276
|
+
delay = Math.min(BACKOFF_BASE_MS * 2 ** (fails - 1), BACKOFF_CAP_MS);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
} finally {
|
|
280
|
+
cleanup();
|
|
281
|
+
}
|
|
282
|
+
}
|