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,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resident agent authentication for QuoxCLI (quox#453 phase 15)
|
|
3
|
+
*
|
|
4
|
+
* A dev server's resident agent authenticates with an Ed25519 key on disk
|
|
5
|
+
* instead of a password, zero human minting. The exchange is a signed
|
|
6
|
+
* challenge:
|
|
7
|
+
*
|
|
8
|
+
* 1. POST {baseUrl}/api/auth/resident/challenge {org_id, host_id}
|
|
9
|
+
* -> {challenge_id, challenge_b64, expires_in}
|
|
10
|
+
* 2. sign the raw challenge bytes with the Ed25519 private key at key_path
|
|
11
|
+
* 3. POST {baseUrl}/api/auth/resident/token {challenge_id, org_id, host_id, signature_b64}
|
|
12
|
+
* -> {access_token, token_type, expires_in, user}
|
|
13
|
+
*
|
|
14
|
+
* Bootstrap file on a dev host (not secret itself, points at the real key):
|
|
15
|
+
* ~/.config/quox/resident-profile.json
|
|
16
|
+
* {kind: 'resident', org_id, host_id, key_id, key_path, instance_url}
|
|
17
|
+
*
|
|
18
|
+
* Neither the private key nor any resulting token is ever printed or logged
|
|
19
|
+
* by this module.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
23
|
+
import { homedir } from 'node:os';
|
|
24
|
+
import { join } from 'node:path';
|
|
25
|
+
import { sign, createPrivateKey } from 'node:crypto';
|
|
26
|
+
import { QuoxError, QuoxAuthError, QuoxTransientAuthError } from './errors.js';
|
|
27
|
+
|
|
28
|
+
export function defaultResidentProfilePath() {
|
|
29
|
+
return join(homedir(), '.config', 'quox', 'resident-profile.json');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function expandHome(p) {
|
|
33
|
+
if (!p) return p;
|
|
34
|
+
if (p === '~') return homedir();
|
|
35
|
+
if (p.startsWith('~/')) return join(homedir(), p.slice(2));
|
|
36
|
+
return p;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Load and validate the resident bootstrap profile.
|
|
41
|
+
* @param {string} [explicitPath] - overrides the default ~/.config/quox/resident-profile.json
|
|
42
|
+
*/
|
|
43
|
+
export function loadResidentProfile(explicitPath) {
|
|
44
|
+
const path = expandHome(explicitPath) || defaultResidentProfilePath();
|
|
45
|
+
|
|
46
|
+
if (!existsSync(path)) {
|
|
47
|
+
throw new QuoxError(
|
|
48
|
+
`No resident profile found at ${path}. A resident agent needs this bootstrap file `
|
|
49
|
+
+ `({kind, org_id, host_id, key_id, key_path, instance_url}) before it can authenticate. `
|
|
50
|
+
+ `Run --resident-profile <path> to point at a different one.`
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let raw;
|
|
55
|
+
try {
|
|
56
|
+
raw = readFileSync(path, 'utf-8');
|
|
57
|
+
} catch (err) {
|
|
58
|
+
throw new QuoxError(`Could not read resident profile at ${path}: ${err.message}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let data;
|
|
62
|
+
try {
|
|
63
|
+
data = JSON.parse(raw);
|
|
64
|
+
} catch (err) {
|
|
65
|
+
throw new QuoxError(`Resident profile at ${path} is not valid JSON: ${err.message}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const required = ['org_id', 'host_id', 'key_path'];
|
|
69
|
+
const missing = required.filter((key) => !data[key]);
|
|
70
|
+
if (missing.length > 0) {
|
|
71
|
+
throw new QuoxError(
|
|
72
|
+
`Resident profile at ${path} is missing required field(s): ${missing.join(', ')}.`
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return { ...data, kind: 'resident', _path: path };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Prefer the bootstrap file's instance_url when the active CLI profile has
|
|
81
|
+
* no explicit URL of its own: a resident agent bootstraps its own instance
|
|
82
|
+
* pointer, it shouldn't need a human to also run `quox config`.
|
|
83
|
+
*/
|
|
84
|
+
export function resolveResidentBaseUrl(config, profile) {
|
|
85
|
+
// quox#455 gap 1: the resident endpoints live on the instance EDGE
|
|
86
|
+
// (https://<host>:3000/api/auth/resident/*, proxied to auth). NEVER
|
|
87
|
+
// derive them by port-swapping to :3101: auth binds loopback on the
|
|
88
|
+
// control box, so :3101 is connection-refused from every remote host.
|
|
89
|
+
if (process.env.QUOX_AUTH_URL) return process.env.QUOX_AUTH_URL;
|
|
90
|
+
if (process.env.QUOX_URL) return process.env.QUOX_URL;
|
|
91
|
+
const activeProfile = typeof config.getActiveProfile === 'function' ? config.getActiveProfile() : null;
|
|
92
|
+
const hasExplicitUrl = !!(activeProfile && activeProfile.url && activeProfile.url !== 'http://localhost:3000');
|
|
93
|
+
if (hasExplicitUrl) return activeProfile.url;
|
|
94
|
+
if (profile?.instance_url) return profile.instance_url;
|
|
95
|
+
// Local default profile on the control box itself: auth is directly
|
|
96
|
+
// reachable on loopback :3101, keep the historical local-dev behaviour.
|
|
97
|
+
return config.getServiceUrl('auth');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* True only when `urlStr` is https AND its host is a bare IP or a
|
|
102
|
+
* .local/LAN-style single-label name: the shapes that can never carry a
|
|
103
|
+
* real, publicly-issued certificate, so a self-signed edge is the expected
|
|
104
|
+
* (not the exceptional) case. A public FQDN with a real cert must never get
|
|
105
|
+
* this baked in, even if it happens to be reachable over the LAN.
|
|
106
|
+
*/
|
|
107
|
+
function isSelfSignedLikelyHost(urlStr) {
|
|
108
|
+
let u;
|
|
109
|
+
try {
|
|
110
|
+
u = new URL(urlStr);
|
|
111
|
+
} catch {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
if (u.protocol !== 'https:') return false;
|
|
115
|
+
const host = u.hostname;
|
|
116
|
+
if (!host) return false;
|
|
117
|
+
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return true; // bare IPv4
|
|
118
|
+
if (host.includes(':')) return true; // bare IPv6 (URL strips the brackets)
|
|
119
|
+
if (/\.local$/i.test(host)) return true; // mDNS / LAN-style
|
|
120
|
+
if (!host.includes('.')) return true; // single-label hostname, never a public FQDN
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Derive the systemd unit environment for a resident's headless supervisor
|
|
126
|
+
* from its bootstrap profile (item 1, live outage 2026-08-23: a unit run
|
|
127
|
+
* directly by systemd inherits none of the caller's shell environment, so
|
|
128
|
+
* every re-auth against a self-signed LAN edge died with a bare "fetch
|
|
129
|
+
* failed"). Baked in once, at install time, from the same profile the
|
|
130
|
+
* resident already trusts for its own identity: QUOX_URL and QUOX_AUTH_URL
|
|
131
|
+
* from instance_url, QUOX_COLLECTOR_URL from collector_url, and
|
|
132
|
+
* QUOX_INSECURE only when isSelfSignedLikelyHost(instance_url) is true.
|
|
133
|
+
* Returns {} for a missing profile or a profile with neither URL: the
|
|
134
|
+
* caller is responsible for warning in that case, this function only ever
|
|
135
|
+
* derives, never decides whether the result is enough.
|
|
136
|
+
*/
|
|
137
|
+
export function deriveSupervisorEnv(profile) {
|
|
138
|
+
const env = {};
|
|
139
|
+
if (!profile) return env;
|
|
140
|
+
if (profile.instance_url) {
|
|
141
|
+
env.QUOX_URL = profile.instance_url;
|
|
142
|
+
env.QUOX_AUTH_URL = profile.instance_url;
|
|
143
|
+
if (isSelfSignedLikelyHost(profile.instance_url)) env.QUOX_INSECURE = '1';
|
|
144
|
+
}
|
|
145
|
+
if (profile.collector_url) env.QUOX_COLLECTOR_URL = profile.collector_url;
|
|
146
|
+
return env;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const TRANSIENT_STATUSES = new Set([429, 502, 503, 504]);
|
|
150
|
+
|
|
151
|
+
// Retry-After is delta-seconds or an HTTP date; returns ms or null.
|
|
152
|
+
function parseRetryAfterMs(res) {
|
|
153
|
+
const raw = res?.headers && typeof res.headers.get === 'function' ? res.headers.get('retry-after') : null;
|
|
154
|
+
if (!raw) return null;
|
|
155
|
+
const secs = Number(raw);
|
|
156
|
+
if (Number.isFinite(secs) && secs >= 0) return Math.round(secs * 1000);
|
|
157
|
+
const at = Date.parse(raw);
|
|
158
|
+
return Number.isFinite(at) ? Math.max(0, at - Date.now()) : null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function exchangeFailure(what, res, data) {
|
|
162
|
+
const msg = `${what} failed (${res.status}): ${data?.error || res.statusText || 'unknown error'}`;
|
|
163
|
+
if (TRANSIENT_STATUSES.has(res.status)) return new QuoxTransientAuthError(msg, parseRetryAfterMs(res));
|
|
164
|
+
return new QuoxAuthError(msg);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function safeJson(res) {
|
|
168
|
+
try {
|
|
169
|
+
return await res.json();
|
|
170
|
+
} catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Run the challenge/sign/token exchange and return the token response.
|
|
177
|
+
* Never logs the private key or the returned token.
|
|
178
|
+
*
|
|
179
|
+
* @param {object} opts
|
|
180
|
+
* @param {string} opts.baseUrl
|
|
181
|
+
* @param {object} opts.profile - result of loadResidentProfile()
|
|
182
|
+
* @param {typeof fetch} [opts.fetchImpl]
|
|
183
|
+
*/
|
|
184
|
+
export async function residentLogin({ baseUrl, profile, fetchImpl = fetch }) {
|
|
185
|
+
const { org_id, host_id, key_path } = profile;
|
|
186
|
+
const keyPath = expandHome(key_path);
|
|
187
|
+
|
|
188
|
+
if (!existsSync(keyPath)) {
|
|
189
|
+
throw new QuoxError(
|
|
190
|
+
`Resident identity key not found at ${keyPath}. Provision the Ed25519 key before logging in.`
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
let keyPem;
|
|
195
|
+
try {
|
|
196
|
+
keyPem = readFileSync(keyPath, 'utf-8');
|
|
197
|
+
} catch (err) {
|
|
198
|
+
throw new QuoxError(`Could not read resident identity key at ${keyPath}: ${err.message}`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
let privateKey;
|
|
202
|
+
try {
|
|
203
|
+
privateKey = createPrivateKey(keyPem);
|
|
204
|
+
} catch (err) {
|
|
205
|
+
throw new QuoxError(`Resident identity key at ${keyPath} is not a valid private key: ${err.message}`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Step 1: request a fresh challenge.
|
|
209
|
+
const challengeRes = await fetchImpl(`${baseUrl}/api/auth/resident/challenge`, {
|
|
210
|
+
method: 'POST',
|
|
211
|
+
headers: { 'Content-Type': 'application/json' },
|
|
212
|
+
body: JSON.stringify({ org_id, host_id }),
|
|
213
|
+
});
|
|
214
|
+
const challengeData = await safeJson(challengeRes);
|
|
215
|
+
if (!challengeRes.ok) {
|
|
216
|
+
throw exchangeFailure('Resident challenge request', challengeRes, challengeData);
|
|
217
|
+
}
|
|
218
|
+
const { challenge_id, challenge_b64 } = challengeData || {};
|
|
219
|
+
if (!challenge_id || !challenge_b64) {
|
|
220
|
+
throw new QuoxError('Resident challenge response is missing challenge_id or challenge_b64.');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Step 2: sign the raw challenge bytes with the Ed25519 key.
|
|
224
|
+
const challengeBytes = Buffer.from(challenge_b64, 'base64');
|
|
225
|
+
const signatureBytes = sign(null, challengeBytes, privateKey);
|
|
226
|
+
const signature_b64 = signatureBytes.toString('base64');
|
|
227
|
+
|
|
228
|
+
// Step 3: exchange the signature for an access token.
|
|
229
|
+
const tokenRes = await fetchImpl(`${baseUrl}/api/auth/resident/token`, {
|
|
230
|
+
method: 'POST',
|
|
231
|
+
headers: { 'Content-Type': 'application/json' },
|
|
232
|
+
body: JSON.stringify({ challenge_id, org_id, host_id, signature_b64 }),
|
|
233
|
+
});
|
|
234
|
+
const tokenData = await safeJson(tokenRes);
|
|
235
|
+
if (!tokenRes.ok) {
|
|
236
|
+
throw exchangeFailure('Resident token exchange', tokenRes, tokenData);
|
|
237
|
+
}
|
|
238
|
+
if (!tokenData?.access_token) {
|
|
239
|
+
throw new QuoxError('Resident token response is missing access_token.');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return tokenData;
|
|
243
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resident pump — QUOXPERTICUS-REVISIT P4 "steer pump".
|
|
3
|
+
*
|
|
4
|
+
* When a resident's slot is held by a FULL INTERACTIVE claude session (the
|
|
5
|
+
* resident itself, deployed via `quox dev resident deploy`), the warden
|
|
6
|
+
* process never answers (see the HOLD-IS-THE-VOICE gate in
|
|
7
|
+
* supervisorEngine.js): the resident already holds the voice. What the
|
|
8
|
+
* warden does instead, while that hold lasts, is PUMP: relay new owner
|
|
9
|
+
* messages from the slot channel into the resident's live tmux pane via the
|
|
10
|
+
* dev-sessions `keys` route (services/collector/lib/devSessionsProxy.js), so
|
|
11
|
+
* the interactive session hears its owner without anyone opening the pane
|
|
12
|
+
* by hand.
|
|
13
|
+
*
|
|
14
|
+
* Live-spike findings this reads (2026-09-04, real injection into a real
|
|
15
|
+
* claude TUI on .131 via tmux send-keys):
|
|
16
|
+
* - Text injected mid-turn queues cleanly in the composer and submits when
|
|
17
|
+
* the turn ends. Injection mid-turn is SAFE.
|
|
18
|
+
* - A permission dialog ("Do you want to proceed? ... Esc to cancel")
|
|
19
|
+
* BLOCKS the pipeline indefinitely. Queued input survives but does not
|
|
20
|
+
* process. This pump NEVER injects bare Enter or any control key to
|
|
21
|
+
* resolve one; a stuck dialog is a residentAlarm.js condition, not
|
|
22
|
+
* something this module resolves. See paneHasDialog below.
|
|
23
|
+
* - `keys` is one line, max 4096 chars (the collector's MAX_KEYS_LENGTH),
|
|
24
|
+
* and the collector appends Enter itself.
|
|
25
|
+
*
|
|
26
|
+
* Every I/O dependency (fetch, pane read, key injection, heartbeat, the
|
|
27
|
+
* clock, sleep) is injected, matching supervisorEngine.js's contract, so
|
|
28
|
+
* this runs under vitest with no real network, no real tmux pane.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { pickNewMessages, boundSeenIds, pruneOldTurns, shouldPostErrorNotice, ERROR_NOTICE_WINDOW_MS, DEFAULT_SETTINGS } from './supervisorEngine.js';
|
|
32
|
+
|
|
33
|
+
// The exact literal markers a Claude Code permission dialog shows
|
|
34
|
+
// ("Do you want to proceed? 1. Yes 2. No ... Esc to cancel"). Either marker
|
|
35
|
+
// alone is enough to hold injection: better to over-detect (delay a message
|
|
36
|
+
// one pass) than under-detect and blind-inject into a live approval prompt.
|
|
37
|
+
export const DIALOG_MARKERS = ['Do you want to proceed?', 'Esc to cancel'];
|
|
38
|
+
|
|
39
|
+
export function paneHasDialog(paneText) {
|
|
40
|
+
const text = String(paneText || '');
|
|
41
|
+
return DIALOG_MARKERS.some((marker) => text.includes(marker));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function sanitizeOneLine(text) {
|
|
45
|
+
return String(text || '').replace(/\r?\n+/g, ' ').replace(/[ \t]{2,}/g, ' ').trim();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Build the `keys` line(s) for one owner message: `[slot:<name> owner]
|
|
50
|
+
* <text>`, newlines collapsed to spaces (a literal newline mid-line would
|
|
51
|
+
* submit a half-typed line early — same reasoning as residentSeed.js's
|
|
52
|
+
* oneLine()). A message that overflows maxLen is CHUNKED, never truncated
|
|
53
|
+
* (silent data loss is worse than several turns): each chunk carries its
|
|
54
|
+
* own prefix and a `(i/n)` suffix so the resident can read each line
|
|
55
|
+
* standalone.
|
|
56
|
+
*/
|
|
57
|
+
export function buildPumpLines({ slotName, text, maxLen = 4000 }) {
|
|
58
|
+
const prefix = `[slot:${slotName} owner] `;
|
|
59
|
+
const sanitized = sanitizeOneLine(text);
|
|
60
|
+
if (prefix.length + sanitized.length <= maxLen) {
|
|
61
|
+
return [`${prefix}${sanitized}`];
|
|
62
|
+
}
|
|
63
|
+
// Reserve headroom for the ` (NN/NN)` suffix appended to every chunk.
|
|
64
|
+
const suffixReserve = 10;
|
|
65
|
+
const available = Math.max(1, maxLen - prefix.length - suffixReserve);
|
|
66
|
+
const parts = [];
|
|
67
|
+
for (let i = 0; i < sanitized.length; i += available) {
|
|
68
|
+
parts.push(sanitized.slice(i, i + available));
|
|
69
|
+
}
|
|
70
|
+
return parts.map((part, i) => `${prefix}${part} (${i + 1}/${parts.length})`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function createInitialPumpState() {
|
|
74
|
+
return {
|
|
75
|
+
bootstrapped: false,
|
|
76
|
+
seen_ids: [],
|
|
77
|
+
// Owner messages picked up from the channel but not yet delivered to
|
|
78
|
+
// the pane: held back by a stuck dialog, or by the per-hour cap. A
|
|
79
|
+
// message only ever leaves `pending` by being injected, never dropped.
|
|
80
|
+
pending: [],
|
|
81
|
+
injection_timestamps: [],
|
|
82
|
+
overflow_notices: [],
|
|
83
|
+
// Set the moment a permission dialog is first observed; cleared the
|
|
84
|
+
// moment it is no longer observed. residentAlarm.js reads the age of
|
|
85
|
+
// this field to decide whether the stuck-dialog condition should alarm.
|
|
86
|
+
stuck_dialog_since: null,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Heartbeat the resident's slot hold with retry/backoff. The pump must not
|
|
92
|
+
* rely on the wait long-poll alone to keep the hold warm: a failed poll
|
|
93
|
+
* pass must never stop heartbeats while the collector is genuinely
|
|
94
|
+
* reachable, so this is a small standalone retry loop, called every pump
|
|
95
|
+
* pass regardless of what else happens this pass.
|
|
96
|
+
*/
|
|
97
|
+
export async function heartbeatWithBackoff({
|
|
98
|
+
heartbeatHold,
|
|
99
|
+
log = () => {},
|
|
100
|
+
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
101
|
+
maxAttempts = 3,
|
|
102
|
+
baseDelayMs = 2000,
|
|
103
|
+
}) {
|
|
104
|
+
let lastErr = null;
|
|
105
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
106
|
+
try {
|
|
107
|
+
await heartbeatHold();
|
|
108
|
+
return { ok: true, attempts: attempt };
|
|
109
|
+
} catch (err) {
|
|
110
|
+
lastErr = err;
|
|
111
|
+
log('warn', `pump: heartbeat attempt ${attempt}/${maxAttempts} failed: ${err.message}`);
|
|
112
|
+
if (attempt < maxAttempts) {
|
|
113
|
+
await sleep(baseDelayMs * attempt);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
log('warn', `pump: heartbeat failed after ${maxAttempts} attempt(s), giving up this pass (hold may lapse): ${lastErr ? lastErr.message : 'unknown error'}`);
|
|
118
|
+
return { ok: false, attempts: maxAttempts, error: lastErr };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Run one pump pass: heartbeat the resident's hold, read the pane for a
|
|
123
|
+
* stuck permission dialog, pick up new owner messages, and inject whatever
|
|
124
|
+
* is safe to inject this pass (never while a dialog is visible, never past
|
|
125
|
+
* the per-hour cap).
|
|
126
|
+
*
|
|
127
|
+
* Injected dependencies:
|
|
128
|
+
* fetchMessages() -> array of slot messages (same shape the engine
|
|
129
|
+
* uses; a `wait`-mode caller can pass the same
|
|
130
|
+
* closure it already built for the headless path)
|
|
131
|
+
* readPane() -> current pane text (string)
|
|
132
|
+
* injectKeys(line) -> POST one `keys` line to the resident's pane
|
|
133
|
+
* heartbeatHold() -> heartbeat the slot AS the resident's own
|
|
134
|
+
* holder_session (not this warden's identity)
|
|
135
|
+
* postReply(text) -> slot `say`, used only for the once-per-hour
|
|
136
|
+
* overflow notice
|
|
137
|
+
*/
|
|
138
|
+
export async function runPumpOnce({
|
|
139
|
+
hostId,
|
|
140
|
+
slot,
|
|
141
|
+
selfIdentity,
|
|
142
|
+
fromFilters = [],
|
|
143
|
+
fetchMessages,
|
|
144
|
+
readPane,
|
|
145
|
+
injectKeys,
|
|
146
|
+
heartbeatHold,
|
|
147
|
+
postReply,
|
|
148
|
+
state,
|
|
149
|
+
log = () => {},
|
|
150
|
+
now = () => Date.now(),
|
|
151
|
+
maxSeenIds = 500,
|
|
152
|
+
maxTurnsPerHour = DEFAULT_SETTINGS.max_turns_per_hour,
|
|
153
|
+
maxLineLen = 4000,
|
|
154
|
+
heartbeatMaxAttempts = 3,
|
|
155
|
+
heartbeatBaseDelayMs = 2000,
|
|
156
|
+
sleep,
|
|
157
|
+
}) {
|
|
158
|
+
const heartbeat = await heartbeatWithBackoff({
|
|
159
|
+
heartbeatHold, log, sleep, maxAttempts: heartbeatMaxAttempts, baseDelayMs: heartbeatBaseDelayMs,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const paneText = await readPane();
|
|
163
|
+
const dialogPresent = paneHasDialog(paneText);
|
|
164
|
+
|
|
165
|
+
let nextState = { ...state, pending: [...(state.pending || [])] };
|
|
166
|
+
|
|
167
|
+
if (dialogPresent) {
|
|
168
|
+
if (nextState.stuck_dialog_since == null) {
|
|
169
|
+
nextState.stuck_dialog_since = now();
|
|
170
|
+
log('warn', `slot ${slot}: permission dialog detected in the resident's pane on ${hostId}; holding owner messages until it clears (never injecting Enter to resolve it).`);
|
|
171
|
+
}
|
|
172
|
+
} else if (nextState.stuck_dialog_since != null) {
|
|
173
|
+
log('info', `slot ${slot}: permission dialog cleared after ${Math.round((now() - nextState.stuck_dialog_since) / 1000)}s; resuming injection.`);
|
|
174
|
+
nextState.stuck_dialog_since = null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const messages = await fetchMessages();
|
|
178
|
+
const firstRun = nextState.bootstrapped !== true;
|
|
179
|
+
const picked = pickNewMessages({ messages, seenIds: nextState.seen_ids, selfIdentity, fromFilters, firstRun });
|
|
180
|
+
nextState.seen_ids = boundSeenIds(picked.seenIds, maxSeenIds);
|
|
181
|
+
if (firstRun) {
|
|
182
|
+
nextState.bootstrapped = true;
|
|
183
|
+
log('info', `pump first run on ${slot}: seeded seen-set with ${picked.allSeen.length} existing message id(s), queuing none for injection.`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const newPending = picked.toAnswer.filter((m) => !nextState.pending.some((p) => p.id === m.id));
|
|
187
|
+
nextState.pending = [...nextState.pending, ...newPending];
|
|
188
|
+
if (newPending.length > 0) {
|
|
189
|
+
log('info', `pump: queued ${newPending.length} new owner message(s) for ${slot}'s resident.`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
let injected = 0;
|
|
193
|
+
let capHit = false;
|
|
194
|
+
|
|
195
|
+
if (!dialogPresent) {
|
|
196
|
+
let timestamps = pruneOldTurns(nextState.injection_timestamps, now(), ERROR_NOTICE_WINDOW_MS);
|
|
197
|
+
const stillPending = [];
|
|
198
|
+
for (const message of nextState.pending) {
|
|
199
|
+
if (timestamps.length >= maxTurnsPerHour) {
|
|
200
|
+
capHit = true;
|
|
201
|
+
stillPending.push(message);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
const lines = buildPumpLines({ slotName: slot, text: message.text, maxLen: maxLineLen });
|
|
205
|
+
for (const line of lines) {
|
|
206
|
+
await injectKeys(line);
|
|
207
|
+
}
|
|
208
|
+
timestamps = [...timestamps, now()];
|
|
209
|
+
injected += 1;
|
|
210
|
+
log('info', `pump: injected message ${message.id} from ${message.from} into ${slot}'s resident pane (${lines.length} line(s)).`);
|
|
211
|
+
}
|
|
212
|
+
nextState.pending = stillPending;
|
|
213
|
+
nextState.injection_timestamps = timestamps;
|
|
214
|
+
} else if (nextState.pending.length > 0) {
|
|
215
|
+
log('warn', `pump: ${nextState.pending.length} owner message(s) on ${slot} held, waiting for the permission dialog to clear.`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (capHit && nextState.pending.length > 0) {
|
|
219
|
+
const notice = shouldPostErrorNotice(nextState.overflow_notices, 'pump_rate_cap', now(), ERROR_NOTICE_WINDOW_MS);
|
|
220
|
+
nextState.overflow_notices = notice.notices;
|
|
221
|
+
if (notice.shouldPost) {
|
|
222
|
+
const text = `Pump injection cap reached (${maxTurnsPerHour}/hour) on slot ${slot}: ${nextState.pending.length} owner message(s) still queued, will resume as the hour rolls over.`;
|
|
223
|
+
await postReply(text);
|
|
224
|
+
log('warn', `pump: posted once-per-hour overflow notice on ${slot} (${nextState.pending.length} still queued).`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return { state: nextState, injected, dialogPresent, pendingCount: nextState.pending.length, heartbeat };
|
|
229
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resident seed prompt — QUOXPERTICUS-REVISIT P3 "Resident lifecycle v1".
|
|
3
|
+
*
|
|
4
|
+
* The seed is the one line of text typed into a freshly-deployed interactive
|
|
5
|
+
* `claude` tmux pane (via the dev-sessions `keys` route) that turns a bare
|
|
6
|
+
* shell into the host's resident: read the revival kit before acting, claim
|
|
7
|
+
* the slot, then say a short hello. This encodes the proven 2026-09-04
|
|
8
|
+
* revival recipe (read MEMORY.md + indexed memories + NOTES_TO_SELF.md
|
|
9
|
+
* before acting, then claim, then greet) as a template, parameterized by
|
|
10
|
+
* host and slot.
|
|
11
|
+
*
|
|
12
|
+
* `keys` is a single line per call (the collector appends Enter — see
|
|
13
|
+
* services/collector/lib/devSessionsProxy.js MAX_KEYS_LENGTH=4096), so the
|
|
14
|
+
* rendered seed collapses to ONE line: no literal newlines, which would
|
|
15
|
+
* otherwise submit a half-typed prompt early.
|
|
16
|
+
*
|
|
17
|
+
* LATER PHASE SEAM: a collector-stored per-org/per-host seed override is a
|
|
18
|
+
* follow-up (dashboard-editable, versioned). Until that lands,
|
|
19
|
+
* `loadSeedOverride()` is the only override point, reading a local file on
|
|
20
|
+
* the CONTROL side (the box running `quox dev resident deploy`) verbatim.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
24
|
+
import { homedir } from 'node:os';
|
|
25
|
+
import { join } from 'node:path';
|
|
26
|
+
|
|
27
|
+
export function defaultSeedOverridePath() {
|
|
28
|
+
return join(homedir(), '.quox', 'resident', 'seed.md');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Read a local seed override file, if present. Returns null when the file
|
|
33
|
+
* is missing, unreadable, or empty — never throws, since a missing override
|
|
34
|
+
* simply means "use the built-in template", not an error.
|
|
35
|
+
*
|
|
36
|
+
* SEAM: a later phase replaces/augments this with a collector-stored
|
|
37
|
+
* config record (org/host-scoped, dashboard-editable). This function is the
|
|
38
|
+
* one place that phase needs to change.
|
|
39
|
+
*/
|
|
40
|
+
export function loadSeedOverride(path = defaultSeedOverridePath()) {
|
|
41
|
+
try {
|
|
42
|
+
if (!existsSync(path)) return null;
|
|
43
|
+
const content = readFileSync(path, 'utf-8');
|
|
44
|
+
return content && content.trim() ? content : null;
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Collapse arbitrary text to one line safe for a single `keys` send. */
|
|
51
|
+
function oneLine(text) {
|
|
52
|
+
return String(text || '').replace(/\s*\r?\n+\s*/g, ' ').replace(/[ \t]{2,}/g, ' ').trim();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function builtInTemplate({ hostId, slotName }) {
|
|
56
|
+
const claimCmd = `quox slot checkout ${slotName} --session resident-${slotName} --kind interactive --host ${hostId} --create`;
|
|
57
|
+
return (
|
|
58
|
+
`You are the resident Claude session for host ${hostId} (slot ${slotName}). ` +
|
|
59
|
+
`Before doing anything else: read ~/.claude/projects/-home-control/memory/MEMORY.md and every ` +
|
|
60
|
+
`memory file it indexes, then read ~/NOTES_TO_SELF.md if it exists, then read any identity or ` +
|
|
61
|
+
`onboarding docs in your home directory for this host. Read all of it before you act on anything. ` +
|
|
62
|
+
`Once you have read everything, claim your slot with: ${claimCmd}. ` +
|
|
63
|
+
`Then post one short hello (1-2 lines, no em dashes, lead with outcome) into the slot channel ` +
|
|
64
|
+
`confirming you are up and what you found.`
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Render the seed prompt for a host/slot pair, as one line.
|
|
70
|
+
* @param {object} params
|
|
71
|
+
* @param {string} params.hostId
|
|
72
|
+
* @param {string} params.slotName
|
|
73
|
+
* @param {string} [params.overrideText] - test seam; when omitted, falls
|
|
74
|
+
* back to loadSeedOverride().
|
|
75
|
+
*/
|
|
76
|
+
export function renderSeedPrompt({ hostId, slotName, overrideText } = {}) {
|
|
77
|
+
if (!hostId) throw new Error('renderSeedPrompt: hostId is required');
|
|
78
|
+
if (!slotName) throw new Error('renderSeedPrompt: slotName is required');
|
|
79
|
+
|
|
80
|
+
const override = overrideText !== undefined ? overrideText : loadSeedOverride();
|
|
81
|
+
if (override) {
|
|
82
|
+
return oneLine(
|
|
83
|
+
override.replace(/\{\{?\s*hostId\s*\}?\}/g, hostId).replace(/\{\{?\s*slotName\s*\}?\}/g, slotName)
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
return oneLine(builtInTemplate({ hostId, slotName }));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Split a one-line seed into <= maxLen chunks, each sent as its own `keys`
|
|
91
|
+
* call (each call appends its own Enter). Under maxLen this is a single
|
|
92
|
+
* chunk — the preferred, normal path. Splitting a seed that overflows
|
|
93
|
+
* maxLen means the resident receives it as multiple separate turns rather
|
|
94
|
+
* than one line, which is a documented degrade, not silent truncation.
|
|
95
|
+
*/
|
|
96
|
+
export function chunkSeedText(text, maxLen = 4000) {
|
|
97
|
+
const s = String(text || '');
|
|
98
|
+
if (s.length <= maxLen) return [s];
|
|
99
|
+
const chunks = [];
|
|
100
|
+
for (let i = 0; i < s.length; i += maxLen) {
|
|
101
|
+
chunks.push(s.slice(i, i + maxLen));
|
|
102
|
+
}
|
|
103
|
+
return chunks;
|
|
104
|
+
}
|