@huynle/hub 0.6.1 → 0.7.2
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/CHANGELOG.md +870 -0
- package/Dockerfile +33 -0
- package/README.md +1007 -66
- package/package.json +47 -2
- package/packages/claude-code/package.json +12 -0
- package/packages/claude-code/src/bridge.ts +79 -0
- package/packages/claude-code/src/daemon.ts +339 -0
- package/packages/claude-code/src/hook-input.ts +44 -0
- package/packages/claude-code/src/ids.ts +27 -0
- package/packages/claude-code/src/index.ts +18 -0
- package/packages/claude-code/src/launcher.ts +265 -0
- package/packages/claude-code/src/main.ts +127 -0
- package/packages/claude-code/src/mcp.ts +109 -0
- package/packages/claude-code/src/paths.ts +34 -0
- package/packages/claude-code/src/plugin.ts +99 -0
- package/packages/claude-code/src/references.ts +51 -0
- package/packages/claude-code/src/sessions.ts +600 -0
- package/packages/claude-code/src/tools.ts +66 -0
- package/packages/claude-code/src/wire.ts +71 -0
- package/packages/claude-code/src/wrapper.ts +156 -0
- package/packages/cli/dist/hub-cli.js +21647 -0
- package/packages/cli/package.json +11 -0
- package/packages/cli/src/acting.ts +70 -0
- package/packages/cli/src/args.ts +91 -0
- package/packages/cli/src/bootstrap/adapters/archive-stream.ts +276 -0
- package/packages/cli/src/bootstrap/adapters/claude/README.md +63 -0
- package/packages/cli/src/bootstrap/adapters/claude/adapter.ts +250 -0
- package/packages/cli/src/bootstrap/adapters/claude/index.ts +42 -0
- package/packages/cli/src/bootstrap/adapters/claude/node.ts +99 -0
- package/packages/cli/src/bootstrap/adapters/claude/openpgp.ts +221 -0
- package/packages/cli/src/bootstrap/adapters/claude/ports.ts +44 -0
- package/packages/cli/src/bootstrap/adapters/claude/source.ts +160 -0
- package/packages/cli/src/bootstrap/adapters/claude/verify.ts +165 -0
- package/packages/cli/src/bootstrap/adapters/codex/archive.ts +103 -0
- package/packages/cli/src/bootstrap/adapters/codex/index.ts +60 -0
- package/packages/cli/src/bootstrap/adapters/codex/plan.ts +129 -0
- package/packages/cli/src/bootstrap/adapters/codex/sigstore.ts +298 -0
- package/packages/cli/src/bootstrap/adapters/codex/source.ts +102 -0
- package/packages/cli/src/bootstrap/adapters/codex/verify.ts +298 -0
- package/packages/cli/src/bootstrap/adapters/inflate.ts +433 -0
- package/packages/cli/src/bootstrap/adapters/node-services/control.ts +392 -0
- package/packages/cli/src/bootstrap/adapters/node-services/index.ts +23 -0
- package/packages/cli/src/bootstrap/adapters/node-services/launch.ts +223 -0
- package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +222 -0
- package/packages/cli/src/bootstrap/adapters/node-services/render.ts +183 -0
- package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +197 -0
- package/packages/cli/src/bootstrap/adapters/opencode/README.md +204 -0
- package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +205 -0
- package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +91 -0
- package/packages/cli/src/bootstrap/adapters/opencode/config.ts +212 -0
- package/packages/cli/src/bootstrap/adapters/opencode/index.ts +81 -0
- package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +266 -0
- package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +208 -0
- package/packages/cli/src/bootstrap/adapters/readiness.ts +19 -0
- package/packages/cli/src/bootstrap/ansible/index.ts +2 -0
- package/packages/cli/src/bootstrap/ansible/render.ts +279 -0
- package/packages/cli/src/bootstrap/contracts/actions.ts +470 -0
- package/packages/cli/src/bootstrap/contracts/artifact.ts +323 -0
- package/packages/cli/src/bootstrap/contracts/canonical.ts +54 -0
- package/packages/cli/src/bootstrap/contracts/credentials.ts +108 -0
- package/packages/cli/src/bootstrap/contracts/discovery.ts +215 -0
- package/packages/cli/src/bootstrap/contracts/envelope.ts +143 -0
- package/packages/cli/src/bootstrap/contracts/exec.ts +74 -0
- package/packages/cli/src/bootstrap/contracts/harness.ts +202 -0
- package/packages/cli/src/bootstrap/contracts/index.ts +28 -0
- package/packages/cli/src/bootstrap/contracts/journal.ts +198 -0
- package/packages/cli/src/bootstrap/contracts/paths.ts +91 -0
- package/packages/cli/src/bootstrap/contracts/plan.ts +155 -0
- package/packages/cli/src/bootstrap/contracts/platform.ts +62 -0
- package/packages/cli/src/bootstrap/contracts/preview.ts +190 -0
- package/packages/cli/src/bootstrap/contracts/profile.ts +105 -0
- package/packages/cli/src/bootstrap/contracts/secrets.ts +52 -0
- package/packages/cli/src/bootstrap/contracts/validate.ts +212 -0
- package/packages/cli/src/bootstrap/discovery/discover.ts +146 -0
- package/packages/cli/src/bootstrap/discovery/executables.ts +134 -0
- package/packages/cli/src/bootstrap/discovery/host.ts +96 -0
- package/packages/cli/src/bootstrap/discovery/index.ts +6 -0
- package/packages/cli/src/bootstrap/discovery/node-probe.ts +97 -0
- package/packages/cli/src/bootstrap/discovery/probe.ts +41 -0
- package/packages/cli/src/bootstrap/discovery/util.ts +13 -0
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +226 -0
- package/packages/cli/src/bootstrap/enrollment/index.ts +7 -0
- package/packages/cli/src/bootstrap/enrollment/policy.ts +54 -0
- package/packages/cli/src/bootstrap/enrollment/result.ts +63 -0
- package/packages/cli/src/bootstrap/enrollment/status.ts +119 -0
- package/packages/cli/src/bootstrap/enrollment/store.ts +173 -0
- package/packages/cli/src/bootstrap/preview/index.ts +5 -0
- package/packages/cli/src/bootstrap/preview/links.ts +31 -0
- package/packages/cli/src/bootstrap/preview/plan.ts +242 -0
- package/packages/cli/src/bootstrap/preview/preview.ts +104 -0
- package/packages/cli/src/bootstrap/preview/render.ts +30 -0
- package/packages/cli/src/bootstrap/profiles/audit.ts +87 -0
- package/packages/cli/src/bootstrap/profiles/catalog.ts +49 -0
- package/packages/cli/src/bootstrap/profiles/desired.ts +96 -0
- package/packages/cli/src/bootstrap/profiles/drift.ts +286 -0
- package/packages/cli/src/bootstrap/profiles/history.ts +263 -0
- package/packages/cli/src/bootstrap/profiles/index.ts +11 -0
- package/packages/cli/src/bootstrap/profiles/upgrade.ts +276 -0
- package/packages/cli/src/bootstrap/profiles/versions.ts +50 -0
- package/packages/cli/src/bootstrap/release/README.md +115 -0
- package/packages/cli/src/bootstrap/release/assets.ts +39 -0
- package/packages/cli/src/bootstrap/release/cli.ts +332 -0
- package/packages/cli/src/bootstrap/release/harness-pins.json +94 -0
- package/packages/cli/src/bootstrap/release/manifest.ts +118 -0
- package/packages/cli/src/bootstrap/release/pins.ts +192 -0
- package/packages/cli/src/bootstrap/release/text-assets.d.ts +9 -0
- package/packages/cli/src/bootstrap/release/trust-root.json +13 -0
- package/packages/cli/src/bootstrap/release/trust-root.ts +39 -0
- package/packages/cli/src/bootstrap/release/verify.ts +88 -0
- package/packages/cli/src/bootstrap/transaction/builtin.ts +312 -0
- package/packages/cli/src/bootstrap/transaction/engine.ts +994 -0
- package/packages/cli/src/bootstrap/transaction/executor.ts +112 -0
- package/packages/cli/src/bootstrap/transaction/fs.ts +194 -0
- package/packages/cli/src/bootstrap/transaction/index.ts +12 -0
- package/packages/cli/src/bootstrap/transaction/json-merge.ts +403 -0
- package/packages/cli/src/bootstrap/transaction/lock.ts +132 -0
- package/packages/cli/src/bootstrap/transaction/resolve.ts +48 -0
- package/packages/cli/src/bootstrap/transaction/result.ts +60 -0
- package/packages/cli/src/bootstrap/transaction/state.ts +88 -0
- package/packages/cli/src/bootstrap/transaction/store.ts +189 -0
- package/packages/cli/src/bootstrap/transaction/stream.ts +254 -0
- package/packages/cli/src/cli.ts +814 -0
- package/packages/cli/src/credentials.ts +75 -0
- package/packages/cli/src/decisions.ts +106 -0
- package/packages/cli/src/format.ts +185 -0
- package/packages/cli/src/grants.ts +105 -0
- package/packages/cli/src/http.ts +86 -0
- package/packages/cli/src/launches.ts +157 -0
- package/packages/cli/src/login.ts +160 -0
- package/packages/cli/src/main.ts +63 -0
- package/packages/cli/src/mcp.ts +879 -0
- package/packages/cli/src/messaging.ts +108 -0
- package/packages/cli/src/node.ts +279 -0
- package/packages/cli/src/openshell.ts +166 -0
- package/packages/cli/src/setup/commands.ts +809 -0
- package/packages/cli/src/setup/contributors.ts +171 -0
- package/packages/cli/src/setup/dispatch.ts +74 -0
- package/packages/cli/src/setup/launch-driver.ts +109 -0
- package/packages/cli/src/setup/runtime.ts +456 -0
- package/packages/cli/src/setup/spoke-probe.ts +120 -0
- package/packages/cli/src/setup/state.ts +244 -0
- package/packages/cli/src/setup/user-bin.ts +39 -0
- package/packages/cli/src/spoke-services.ts +109 -0
- package/packages/cli/src/watch.ts +28 -0
- package/packages/codex/package.json +12 -0
- package/packages/codex/src/app-server.ts +44 -0
- package/packages/codex/src/ids.ts +28 -0
- package/packages/codex/src/index.ts +11 -0
- package/packages/codex/src/jsonrpc.ts +168 -0
- package/packages/codex/src/main.ts +123 -0
- package/packages/codex/src/paths.ts +65 -0
- package/packages/codex/src/sessions.ts +578 -0
- package/packages/codex/src/spoke.ts +252 -0
- package/packages/codex/src/stdio.ts +101 -0
- package/packages/codex/src/websocket.ts +206 -0
- package/packages/codex/src/wrapper.ts +104 -0
- package/packages/node/package.json +12 -0
- package/packages/node/src/config.ts +184 -0
- package/packages/node/src/connection.ts +283 -0
- package/packages/node/src/daemon.ts +281 -0
- package/packages/node/src/git.ts +107 -0
- package/packages/node/src/harnesses.ts +95 -0
- package/packages/node/src/host.ts +319 -0
- package/packages/node/src/index.ts +17 -0
- package/packages/node/src/main.ts +11 -0
- package/packages/node/src/openshell.ts +73 -0
- package/packages/node/src/run.ts +106 -0
- package/packages/preset/package.json +12 -0
- package/packages/preset/presets/aider.json +8 -0
- package/packages/preset/presets/goose.json +8 -0
- package/packages/preset/src/index.ts +2 -0
- package/packages/preset/src/main.ts +195 -0
- package/packages/preset/src/presets.ts +87 -0
- package/packages/preset/src/spoke.ts +251 -0
- package/packages/preset/src/tmux.ts +160 -0
- package/packages/protocol/package.json +12 -0
- package/packages/protocol/src/harness-events.ts +207 -0
- package/packages/protocol/src/index.ts +1215 -0
- package/packages/protocol/src/launch.ts +172 -0
- package/packages/protocol/src/node.ts +409 -0
- package/packages/protocol/src/preset.ts +144 -0
- package/packages/protocol/src/webhook.ts +62 -0
- package/packages/protocol/src/wire.ts +54 -0
- package/packages/runtime-openshell/image/Dockerfile +36 -0
- package/packages/runtime-openshell/image/README.md +78 -0
- package/packages/runtime-openshell/image/build.ts +203 -0
- package/packages/runtime-openshell/image/entry.sh +163 -0
- package/packages/runtime-openshell/image/image.ts +119 -0
- package/packages/runtime-openshell/image/start.sh +24 -0
- package/packages/runtime-openshell/package.json +12 -0
- package/packages/runtime-openshell/src/cli.ts +584 -0
- package/packages/runtime-openshell/src/index.ts +6 -0
- package/packages/runtime-openshell/src/policy.ts +155 -0
- package/packages/runtime-openshell/src/profiles.ts +181 -0
- package/packages/runtime-openshell/src/runtime.ts +475 -0
- package/packages/runtime-sdk/package.json +12 -0
- package/packages/runtime-sdk/src/index.ts +87 -0
- package/packages/sdk/package.json +12 -0
- package/packages/sdk/src/address.ts +25 -0
- package/packages/sdk/src/client.ts +553 -0
- package/packages/sdk/src/envelope.ts +108 -0
- package/packages/sdk/src/errors.ts +107 -0
- package/packages/sdk/src/index.ts +10 -0
- package/packages/sdk/src/sse.ts +70 -0
- package/packages/sdk/src/types.ts +517 -0
- package/packages/sdk/src/webhook.ts +102 -0
- package/packages/spoke-sdk/package.json +12 -0
- package/packages/spoke-sdk/src/activity.ts +3 -0
- package/packages/spoke-sdk/src/connection.ts +295 -0
- package/packages/spoke-sdk/src/delivery.ts +119 -0
- package/packages/spoke-sdk/src/enroll.ts +101 -0
- package/packages/spoke-sdk/src/harness-hello.ts +79 -0
- package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
- package/packages/spoke-sdk/src/index.ts +12 -0
- package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
- package/packages/spoke-sdk/src/prompt-source.ts +8 -0
- package/packages/spoke-sdk/src/session-ids.ts +49 -0
- package/packages/spoke-sdk/src/session-tokens.ts +78 -0
- package/scripts/hub-claude-code.service +34 -0
- package/scripts/hub-codex-spoke.service +34 -0
- package/scripts/hub-node.service +35 -0
- package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
- package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
- package/scripts/io.github.huynle.hub-node.plist +52 -0
- package/src/central/actions.ts +233 -0
- package/src/central/agent/acting-routes.ts +133 -0
- package/src/central/agent/acting-switch.ts +93 -0
- package/src/central/agent/acting.ts +599 -0
- package/src/central/agent/actor.ts +193 -0
- package/src/central/agent/app-tokens.ts +152 -0
- package/src/central/agent/archive-inactive.ts +66 -0
- package/src/central/agent/audit.ts +185 -0
- package/src/central/agent/decisions.ts +530 -0
- package/src/central/agent/delivery.ts +678 -0
- package/src/central/agent/directory-routes.ts +328 -0
- package/src/central/agent/directory.ts +473 -0
- package/src/central/agent/envelope.ts +51 -0
- package/src/central/agent/errors.ts +74 -0
- package/src/central/agent/escalation.ts +219 -0
- package/src/central/agent/event-cursor.ts +49 -0
- package/src/central/agent/event-writers.ts +181 -0
- package/src/central/agent/events-routes.ts +148 -0
- package/src/central/agent/events.ts +277 -0
- package/src/central/agent/grant-requests.ts +221 -0
- package/src/central/agent/grant-routes.ts +70 -0
- package/src/central/agent/grants.ts +235 -0
- package/src/central/agent/launch-engine.ts +1025 -0
- package/src/central/agent/launch-routes.ts +252 -0
- package/src/central/agent/launch-spec.ts +289 -0
- package/src/central/agent/launch-store.ts +264 -0
- package/src/central/agent/launch-tokens.ts +206 -0
- package/src/central/agent/message-limits.ts +123 -0
- package/src/central/agent/message-routes.ts +385 -0
- package/src/central/agent/messages.ts +442 -0
- package/src/central/agent/names.ts +86 -0
- package/src/central/agent/placement.ts +419 -0
- package/src/central/agent/policy.ts +378 -0
- package/src/central/agent/read-routes.ts +385 -0
- package/src/central/agent/routes.ts +281 -0
- package/src/central/agent/session-tokens.ts +227 -0
- package/src/central/agent/versioned.ts +26 -0
- package/src/central/agent/watch.ts +112 -0
- package/src/central/agent/webhook-routes.ts +86 -0
- package/src/central/agent/webhooks.ts +493 -0
- package/src/central/agent-data.ts +177 -0
- package/src/central/auth.ts +350 -121
- package/src/central/backend/file-state.ts +240 -0
- package/src/central/backend/index.ts +116 -0
- package/src/central/backend/memory-bus.ts +93 -0
- package/src/central/backend/memory-catalog.ts +90 -0
- package/src/central/backend/memory-counters.ts +102 -0
- package/src/central/backend/memory-events.ts +45 -0
- package/src/central/backend/memory-kv.ts +38 -0
- package/src/central/backend/memory-state.ts +77 -0
- package/src/central/backend/postgres-migrate.ts +100 -0
- package/src/central/backend/postgres-state.ts +197 -0
- package/src/central/backend/types.ts +246 -0
- package/src/central/backend/valkey-bus.ts +425 -0
- package/src/central/backend/valkey-catalog.ts +190 -0
- package/src/central/backend/valkey-client.ts +36 -0
- package/src/central/backend/valkey-counters.ts +38 -0
- package/src/central/backend/valkey-events.ts +168 -0
- package/src/central/backend/valkey-kv.ts +54 -0
- package/src/central/backend/valkey-locks.ts +92 -0
- package/src/central/cluster/directory.ts +141 -0
- package/src/central/cluster/feed.ts +84 -0
- package/src/central/cluster/leader.ts +232 -0
- package/src/central/cluster/live.ts +151 -0
- package/src/central/cluster/recommend.ts +59 -0
- package/src/central/cluster/rpc.ts +150 -0
- package/src/central/cluster/view.ts +232 -0
- package/src/central/config.ts +197 -3
- package/src/central/done.ts +96 -0
- package/src/central/events-log.ts +100 -0
- package/src/central/http.ts +1149 -170
- package/src/central/import.ts +318 -0
- package/src/central/main.ts +211 -12
- package/src/central/mcp-http.ts +116 -0
- package/src/central/nodes.ts +314 -0
- package/src/central/oidc.ts +34 -14
- package/src/central/push-target.ts +118 -18
- package/src/central/push.ts +256 -82
- package/src/central/registry.ts +902 -92
- package/src/central/say-stream.ts +128 -0
- package/src/central/session-token-push.ts +233 -0
- package/src/central/speech/openai.ts +21 -14
- package/src/central/speech/openrouter.ts +27 -10
- package/src/central/speech/provider.ts +54 -0
- package/src/central/speech/service.ts +22 -7
- package/src/central/spoke-ws.ts +522 -44
- package/src/central/store-shared.ts +518 -0
- package/src/central/store.ts +625 -189
- package/src/central/token-keys.ts +165 -0
- package/src/config.ts +118 -7
- package/src/index.ts +195 -43
- package/src/protocol.ts +3 -650
- package/src/rpc.ts +27 -2
- package/src/spoke/client.ts +3 -272
- package/src/spoke/conversation.ts +16 -12
- package/src/spoke/deliver.ts +75 -0
- package/src/spoke/enroll.ts +2 -79
- package/src/spoke/env-inject.ts +160 -0
- package/src/spoke/hub-rpc.ts +115 -0
- package/src/spoke/hub-tool.ts +409 -0
- package/src/spoke/launch-source.ts +22 -0
- package/src/spoke/launchers.ts +174 -0
- package/src/spoke/locations.ts +3 -2
- package/src/spoke/node-roots.ts +61 -0
- package/src/spoke/prompt-refs.ts +116 -0
- package/src/spoke/session-tokens.ts +2 -0
- package/src/spoke/spoke.ts +465 -28
- package/src/spoke/taskboard.ts +243 -0
- package/src/spoke/timeline.ts +184 -6
- package/src/spoke/tracker.ts +79 -4
- package/src/status.ts +203 -10
- package/src/tui-commands.ts +13 -6
- package/src/tui-messaging.ts +93 -0
- package/src/tui.ts +15 -0
- package/web/dist/apple-touch-icon.png +0 -0
- package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
- package/web/dist/assets/index-DWnMhXk9.js +20 -0
- package/web/dist/assets/index-DmUXEnw9.css +1 -0
- package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
- package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
- package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
- package/web/dist/favicon.ico +0 -0
- package/web/dist/icon-192.png +0 -0
- package/web/dist/icon-512.png +0 -0
- package/web/dist/icon-maskable-512.png +0 -0
- package/web/dist/icon.svg +5 -5
- package/web/dist/index.html +17 -4
- package/web/dist/manifest.webmanifest +2 -2
- package/web/dist/sw.js +79 -15
- package/web/dist/assets/index-DClBvfLL.css +0 -1
- package/web/dist/assets/index-Djo_yF0a.js +0 -12
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// What `hubctl setup` keeps between commands (HBS-13 integration item 7). Everything is secret-free, validated when
|
|
2
|
+
// read and written, and reached only through symlink-checked paths under the state root (next to the applier's own
|
|
3
|
+
// journal, contract LOCK_PATH):
|
|
4
|
+
//
|
|
5
|
+
// the setup request `setup preview --out <file>`: the SetupPreview plus the profile and choices apply must
|
|
6
|
+
// re-plan with, so `setup apply --preview <file>` needs nothing else (the Ansible role's argv)
|
|
7
|
+
// state:hub/bootstrap/state.json
|
|
8
|
+
// the applied profile, its choices and catalog revision, the last committed transaction and
|
|
9
|
+
// plan digest, and the security floors this machine has seen (hubctl and each harness's
|
|
10
|
+
// pinned minimumVersion), which only rise
|
|
11
|
+
// state:hub/bootstrap/requests/<hex>.json
|
|
12
|
+
// the request each applied plan was made from, by plan digest (resume, drift and verify
|
|
13
|
+
// re-plan from it); the plan itself is the engine's journal/<txID>.plan.json
|
|
14
|
+
//
|
|
15
|
+
// The journals stay the record of used preview nonces (the engine reads them).
|
|
16
|
+
import {
|
|
17
|
+
bool,
|
|
18
|
+
Checker,
|
|
19
|
+
compareVersions,
|
|
20
|
+
digest,
|
|
21
|
+
ENROLLMENT_MODES,
|
|
22
|
+
findSecrets,
|
|
23
|
+
HARNESS_ORDER,
|
|
24
|
+
header,
|
|
25
|
+
int,
|
|
26
|
+
isRecord,
|
|
27
|
+
oneOf,
|
|
28
|
+
semver,
|
|
29
|
+
shape,
|
|
30
|
+
text,
|
|
31
|
+
validatePathRef,
|
|
32
|
+
validateSetupPreview,
|
|
33
|
+
type ArtifactManifest,
|
|
34
|
+
type EnrollmentMode,
|
|
35
|
+
type HarnessID,
|
|
36
|
+
type PathRef,
|
|
37
|
+
type Profile,
|
|
38
|
+
type Result,
|
|
39
|
+
type RootMapping,
|
|
40
|
+
type SetupPreview,
|
|
41
|
+
} from "../bootstrap/contracts/index.ts"
|
|
42
|
+
import { loadProfile } from "../bootstrap/profiles/versions.ts"
|
|
43
|
+
import { nodeFs, type TxFs } from "../bootstrap/transaction/fs.ts"
|
|
44
|
+
import { resolveTarget } from "../bootstrap/transaction/resolve.ts"
|
|
45
|
+
import { errnoOf } from "../bootstrap/transaction/result.ts"
|
|
46
|
+
|
|
47
|
+
export const SETUP_REQUEST_SCHEMA = "hub.setup-request"
|
|
48
|
+
export const BOOTSTRAP_STATE_SCHEMA = "hub.bootstrap-state"
|
|
49
|
+
|
|
50
|
+
/** What the user chose at preview, besides the profile; apply re-plans with exactly these. */
|
|
51
|
+
export interface SetupChoices {
|
|
52
|
+
/** Inline (a planned enroll-machine, with `server`) or separate (`hubctl machine enroll`). */
|
|
53
|
+
readonly enrollment?: EnrollmentMode
|
|
54
|
+
/** --confirm-activation at preview: plan loading the services (apply needs the flag again). */
|
|
55
|
+
readonly activate?: boolean
|
|
56
|
+
/** --allow-downgrade at preview (apply needs the flag again). */
|
|
57
|
+
readonly allowDowngrade?: boolean
|
|
58
|
+
/** node.json settings for a launch node. */
|
|
59
|
+
readonly node?: { readonly workspaceRoots: readonly string[]; readonly slots?: number; readonly labels?: Readonly<Record<string, string>> }
|
|
60
|
+
/** One bounded verification launch. */
|
|
61
|
+
readonly launch?: { readonly harness: HarnessID; readonly workspace: PathRef }
|
|
62
|
+
/** The hub to enroll with inline. */
|
|
63
|
+
readonly server?: string
|
|
64
|
+
readonly machineName?: string
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface SetupRequest {
|
|
68
|
+
readonly schema: typeof SETUP_REQUEST_SCHEMA
|
|
69
|
+
readonly version: 1
|
|
70
|
+
readonly preview: SetupPreview
|
|
71
|
+
readonly profile: Profile
|
|
72
|
+
readonly choices: SetupChoices
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface Floors {
|
|
76
|
+
readonly hubctl?: string
|
|
77
|
+
readonly harnesses: Partial<Record<HarnessID, string>>
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface BootstrapState {
|
|
81
|
+
readonly schema: typeof BOOTSTRAP_STATE_SCHEMA
|
|
82
|
+
readonly version: 1
|
|
83
|
+
/** The profile of the last committed apply. */
|
|
84
|
+
readonly profile?: Profile
|
|
85
|
+
readonly choices?: SetupChoices
|
|
86
|
+
/** The built-in catalog revision that profile was made from (profile.revision). */
|
|
87
|
+
readonly catalogRevision?: number
|
|
88
|
+
readonly txID?: string
|
|
89
|
+
readonly planDigest?: string
|
|
90
|
+
readonly floors: Floors
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const EMPTY: BootstrapState = { schema: BOOTSTRAP_STATE_SCHEMA, version: 1, floors: { harnesses: {} } }
|
|
94
|
+
|
|
95
|
+
function checkChoices(c: Checker, path: string, value: unknown): void {
|
|
96
|
+
const r = shape(c, path, value, [], ["enrollment", "activate", "allowDowngrade", "node", "launch", "server", "machineName"])
|
|
97
|
+
if (!r) return
|
|
98
|
+
oneOf(c, `${path}/enrollment`, r.enrollment, ENROLLMENT_MODES)
|
|
99
|
+
bool(c, `${path}/activate`, r.activate)
|
|
100
|
+
bool(c, `${path}/allowDowngrade`, r.allowDowngrade)
|
|
101
|
+
if (r.node !== undefined) {
|
|
102
|
+
const n = shape(c, `${path}/node`, r.node, ["workspaceRoots"], ["slots", "labels"])
|
|
103
|
+
if (n) {
|
|
104
|
+
if (!Array.isArray(n.workspaceRoots) || n.workspaceRoots.length > 64) c.add(`${path}/node/workspaceRoots`, "invalid_value", "expected a list of at most 64 paths")
|
|
105
|
+
else n.workspaceRoots.forEach((w, i) => text(c, `${path}/node/workspaceRoots/${i}`, w, 4096))
|
|
106
|
+
int(c, `${path}/node/slots`, n.slots, 1, 64)
|
|
107
|
+
if (n.labels !== undefined && (!isRecord(n.labels) || Object.values(n.labels).some((v) => typeof v !== "string"))) c.add(`${path}/node/labels`, "invalid_value", "labels are strings")
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (r.launch !== undefined) {
|
|
111
|
+
const l = shape(c, `${path}/launch`, r.launch, ["harness", "workspace"])
|
|
112
|
+
if (l) {
|
|
113
|
+
oneOf(c, `${path}/launch/harness`, l.harness, HARNESS_ORDER)
|
|
114
|
+
c.merge(`${path}/launch/workspace`, validatePathRef(l.workspace))
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
text(c, `${path}/server`, r.server, 2048)
|
|
118
|
+
text(c, `${path}/machineName`, r.machineName, 64)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** A setup request read back from a file: validated, never cast; the profile is migrated to the current version. */
|
|
122
|
+
export function validateSetupRequest(value: unknown): Result<SetupRequest> {
|
|
123
|
+
const c = new Checker()
|
|
124
|
+
const r = shape(c, "", value, ["schema", "version", "preview", "profile", "choices"])
|
|
125
|
+
if (!r) return c.result(undefined)
|
|
126
|
+
header(c, "", r, SETUP_REQUEST_SCHEMA, 1)
|
|
127
|
+
c.merge("/preview", validateSetupPreview(r.preview))
|
|
128
|
+
const profile = loadProfile(r.profile)
|
|
129
|
+
c.merge("/profile", profile)
|
|
130
|
+
checkChoices(c, "/choices", r.choices)
|
|
131
|
+
// The preview is checked by its own validator (its token field is a preview token, not a credential).
|
|
132
|
+
for (const p of findSecrets({ profile: r.profile, choices: r.choices })) c.add(p, "secret", "setup requests never carry credentials")
|
|
133
|
+
if (c.issues.length > 0) return c.result(undefined)
|
|
134
|
+
return c.result({ ...r, profile: profile.ok ? profile.value.profile : r.profile })
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function validateState(value: unknown): Result<BootstrapState> {
|
|
138
|
+
const c = new Checker()
|
|
139
|
+
const r = shape(c, "", value, ["schema", "version", "floors"], ["profile", "choices", "catalogRevision", "txID", "planDigest"])
|
|
140
|
+
if (!r) return c.result(undefined)
|
|
141
|
+
header(c, "", r, BOOTSTRAP_STATE_SCHEMA, 1)
|
|
142
|
+
if (r.profile !== undefined) c.merge("/profile", loadProfile(r.profile))
|
|
143
|
+
if (r.choices !== undefined) checkChoices(c, "/choices", r.choices)
|
|
144
|
+
int(c, "/catalogRevision", r.catalogRevision, 1, 1_000_000)
|
|
145
|
+
text(c, "/txID", r.txID, 64, /^tx_[A-Za-z0-9_-]+$/)
|
|
146
|
+
if (r.planDigest !== undefined) digest(c, "/planDigest", r.planDigest)
|
|
147
|
+
const f = shape(c, "/floors", r.floors, ["harnesses"], ["hubctl"])
|
|
148
|
+
if (f) {
|
|
149
|
+
if (f.hubctl !== undefined) semver(c, "/floors/hubctl", f.hubctl)
|
|
150
|
+
const h = shape(c, "/floors/harnesses", f.harnesses, [], HARNESS_ORDER)
|
|
151
|
+
if (h) for (const id of HARNESS_ORDER) if (h[id] !== undefined) semver(c, `/floors/harnesses/${id}`, h[id])
|
|
152
|
+
}
|
|
153
|
+
for (const p of findSecrets(value)) c.add(p, "secret", "the bootstrap state never carries credentials")
|
|
154
|
+
return c.result(value)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const max = (a: string | undefined, b: string | undefined) => (a === undefined ? b : b === undefined ? a : compareVersions(a, b) >= 0 ? a : b)
|
|
158
|
+
|
|
159
|
+
/** The floors after seeing a verified manifest: the highest of what was seen and what it pins for `target`. */
|
|
160
|
+
export function raiseFloors(floors: Floors, manifest: Pick<ArtifactManifest, "minimumVersion" | "harnesses">, target: string): Floors {
|
|
161
|
+
const harnesses: Partial<Record<HarnessID, string>> = { ...floors.harnesses }
|
|
162
|
+
for (const pin of manifest.harnesses) if (pin.target === target && pin.minimumVersion) harnesses[pin.harness] = max(harnesses[pin.harness], pin.minimumVersion)
|
|
163
|
+
const hubctl = max(floors.hubctl, manifest.minimumVersion)
|
|
164
|
+
return { ...(hubctl ? { hubctl } : {}), harnesses }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const decode = (bytes: Uint8Array) => new TextDecoder().decode(bytes)
|
|
168
|
+
|
|
169
|
+
export class BootstrapStateStore {
|
|
170
|
+
readonly statePath: string
|
|
171
|
+
private readonly requestDir: string
|
|
172
|
+
|
|
173
|
+
constructor(
|
|
174
|
+
private readonly roots: readonly RootMapping[],
|
|
175
|
+
private readonly fs: TxFs = nodeFs,
|
|
176
|
+
) {
|
|
177
|
+
const state = this.resolve("hub/bootstrap/state.json")
|
|
178
|
+
this.statePath = state
|
|
179
|
+
this.requestDir = `${state.slice(0, state.lastIndexOf("/"))}/requests`
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private resolve(rel: string): string {
|
|
183
|
+
const r = resolveTarget({ root: "state", rel }, this.roots, this.fs)
|
|
184
|
+
if (!r.ok) throw new Error(`the hub state directory is unusable: ${r.message}`)
|
|
185
|
+
return r.value
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
read(): BootstrapState {
|
|
189
|
+
const info = this.fs.lstat(this.resolve("hub/bootstrap/state.json"))
|
|
190
|
+
if (!info) return EMPTY
|
|
191
|
+
let parsed: unknown
|
|
192
|
+
try {
|
|
193
|
+
parsed = JSON.parse(decode(this.fs.readFile(this.statePath)))
|
|
194
|
+
} catch {
|
|
195
|
+
throw new Error(`the bootstrap state ${this.statePath} cannot be read; inspect it before setting up again`)
|
|
196
|
+
}
|
|
197
|
+
const checked = validateState(parsed)
|
|
198
|
+
if (!checked.ok) throw new Error(`the bootstrap state ${this.statePath} is not valid (${checked.issues[0]!.message})`)
|
|
199
|
+
return checked.value
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
write(state: BootstrapState): void {
|
|
203
|
+
const checked = validateState(state)
|
|
204
|
+
if (!checked.ok) throw new Error(`refusing to write an invalid bootstrap state: ${checked.issues[0]!.path} ${checked.issues[0]!.message}`)
|
|
205
|
+
this.writeAtomic(this.resolve("hub/bootstrap/state.json"), state)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Records the request a plan was made from, under its plan digest. */
|
|
209
|
+
writeRequest(request: SetupRequest): void {
|
|
210
|
+
const checked = validateSetupRequest(request)
|
|
211
|
+
if (!checked.ok) throw new Error(`refusing to store an invalid setup request: ${checked.issues[0]!.message}`)
|
|
212
|
+
this.writeAtomic(this.resolve(`hub/bootstrap/requests/${request.preview.planDigest.slice("sha256:".length)}.json`), request)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
readRequest(planDigest: string): SetupRequest | undefined {
|
|
216
|
+
if (!/^sha256:[0-9a-f]{64}$/.test(planDigest)) return undefined
|
|
217
|
+
const path = this.resolve(`hub/bootstrap/requests/${planDigest.slice("sha256:".length)}.json`)
|
|
218
|
+
if (!this.fs.lstat(path)) return undefined
|
|
219
|
+
try {
|
|
220
|
+
const r = validateSetupRequest(JSON.parse(decode(this.fs.readFile(path))))
|
|
221
|
+
return r.ok && r.value.preview.planDigest === planDigest ? r.value : undefined
|
|
222
|
+
} catch {
|
|
223
|
+
return undefined
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private writeAtomic(path: string, value: unknown): void {
|
|
228
|
+
const dir = path.slice(0, path.lastIndexOf("/"))
|
|
229
|
+
if (!this.fs.lstat(dir)) this.fs.mkdirp(dir, 0o700)
|
|
230
|
+
else if (this.fs.lstat(dir)?.kind !== "directory") throw new Error(`${dir} is not a directory`)
|
|
231
|
+
const tmp = `${path}.tmp`
|
|
232
|
+
try {
|
|
233
|
+
if (this.fs.lstat(tmp)) this.fs.unlink(tmp)
|
|
234
|
+
this.fs.writeExclusive(tmp, `${JSON.stringify(value, null, 2)}\n`, 0o600)
|
|
235
|
+
this.fs.rename(tmp, path)
|
|
236
|
+
this.fs.fsyncDir(dir)
|
|
237
|
+
} catch (error) {
|
|
238
|
+
try {
|
|
239
|
+
if (this.fs.lstat(tmp)) this.fs.unlink(tmp)
|
|
240
|
+
} catch {}
|
|
241
|
+
throw errnoOf(error) ? new Error(`cannot write ${path} (${errnoOf(error)})`) : error
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// The user-bin link rule (contract §7.6 A6, HBS-12 E4), shared by the setup's harness links (contributors.ts) and
|
|
2
|
+
// the OpenCode plugin's `hub` link (src/spoke/launchers.ts): hub links a command into user-bin (~/.local/bin) only
|
|
3
|
+
// when nothing is there or what is there is a link of hub's own, and only when no other command of that name is on
|
|
4
|
+
// PATH. A user's binary is never shadowed or replaced. Pure: callers read the facts (lstat, readlink, PATH).
|
|
5
|
+
|
|
6
|
+
/** What is at the link path now, not following a final symlink. */
|
|
7
|
+
export type UserBinEntry = "missing" | "file" | "directory" | "symlink" | "other"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* - in-place: the link is already there, pointing at the target.
|
|
11
|
+
* - create: nothing there: link it.
|
|
12
|
+
* - replace: a link of hub's own pointing elsewhere: re-point it.
|
|
13
|
+
* - foreign: something of the user's is there: leave it.
|
|
14
|
+
* - shadowed: another command of that name comes first on PATH: leave it.
|
|
15
|
+
*/
|
|
16
|
+
export type UserBinLinkDecision = "in-place" | "create" | "replace" | "foreign" | "shadowed"
|
|
17
|
+
|
|
18
|
+
export interface UserBinLinkFacts {
|
|
19
|
+
readonly entry: UserBinEntry
|
|
20
|
+
/** The absolute path a symlink at the link path points to. */
|
|
21
|
+
readonly pointsTo?: string
|
|
22
|
+
/** The absolute path the link should point to. */
|
|
23
|
+
readonly target: string
|
|
24
|
+
/** The link path itself. */
|
|
25
|
+
readonly linkPath: string
|
|
26
|
+
/** Whether a link pointing here is hub's own (its harness directory, its launchers). */
|
|
27
|
+
readonly hubOwned: (pointsTo: string) => boolean
|
|
28
|
+
/** The first command of that name on PATH, as found (not followed); undefined when none. */
|
|
29
|
+
readonly foundOnPath?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function userBinLinkDecision(facts: UserBinLinkFacts): UserBinLinkDecision {
|
|
33
|
+
const { entry, pointsTo } = facts
|
|
34
|
+
if (entry === "symlink" && pointsTo === facts.target) return "in-place"
|
|
35
|
+
const hubOwned = entry === "symlink" && pointsTo !== undefined && facts.hubOwned(pointsTo)
|
|
36
|
+
if (entry !== "missing" && !hubOwned) return "foreign"
|
|
37
|
+
if (facts.foundOnPath !== undefined && facts.foundOnPath !== facts.linkPath) return "shadowed"
|
|
38
|
+
return entry === "missing" ? "create" : "replace"
|
|
39
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// `hub claude-code install | uninstall | status` and `hub codex-spoke install | uninstall | status`: the harness
|
|
2
|
+
// spokes' per-user services (`hub claude-code daemon`, `hub codex-spoke serve`) as a launchd agent (macOS) or a
|
|
3
|
+
// systemd user unit (Linux), rendered from the templates embedded in this program the way `hub node install` writes
|
|
4
|
+
// the node's (node.ts). `hub claude` and `hub codex` start these processes on demand; installed, they keep running, so the hub
|
|
5
|
+
// can place claude-code and codex launches on the machine without a wrapper run first. Like `hub node install`,
|
|
6
|
+
// nothing here starts a process: loading, unloading and inspecting the service are printed for the user, never run.
|
|
7
|
+
import path from "node:path"
|
|
8
|
+
import { claudeBinary, codexBinary, findExecutable } from "../../node/src/harnesses.ts"
|
|
9
|
+
import { readSpokeCredentials } from "../../node/src/config.ts"
|
|
10
|
+
import { socketPath } from "../../claude-code/src/paths.ts"
|
|
11
|
+
import { daemonSocketPath, readServicePid } from "../../codex/src/paths.ts"
|
|
12
|
+
import type { Env } from "./credentials"
|
|
13
|
+
import { installService, serviceCheckLines, serviceState, serviceStateLine, uninstallService, type ExtraEnvironment, type NodeCommandOutput, type NodeIo, type ServiceSpec, type ServiceState } from "./node"
|
|
14
|
+
|
|
15
|
+
/** The subcommands `hub claude-code` and `hub codex-spoke` take for their service (the others are the spokes' own). */
|
|
16
|
+
export const SERVICE_VERBS = ["install", "uninstall", "status"] as const
|
|
17
|
+
export type ServiceVerb = (typeof SERVICE_VERBS)[number]
|
|
18
|
+
export const isServiceVerb = (verb: string | undefined): verb is ServiceVerb => (SERVICE_VERBS as readonly string[]).includes(verb ?? "")
|
|
19
|
+
|
|
20
|
+
/** Where config.json and the hub state dir are (shared by every hub spoke). */
|
|
21
|
+
const HUB_PLACES = ["HUB_SPOKE_CONFIG", "XDG_CONFIG_HOME", "HUB_STATE_DIR"] as const
|
|
22
|
+
|
|
23
|
+
export const CLAUDE_CODE_SERVICE: ServiceSpec = {
|
|
24
|
+
service: "claude-code",
|
|
25
|
+
command: "hub claude-code",
|
|
26
|
+
label: "io.github.huynle.hub-claude-code",
|
|
27
|
+
unit: "hub-claude-code.service",
|
|
28
|
+
log: "claude-code.log",
|
|
29
|
+
// The hooks find the daemon by HUB_CLAUDE_SOCKET / HUB_STATE_DIR: the service must listen where they look.
|
|
30
|
+
carried: [...HUB_PLACES, "HUB_CLAUDE_SOCKET", "HUB_CLAUDE_BIN"],
|
|
31
|
+
elsewhere: "Run `hub claude-code daemon` under your own service manager (or let `hub claude` start it)",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const CODEX_SPOKE_SERVICE: ServiceSpec = {
|
|
35
|
+
service: "codex-spoke",
|
|
36
|
+
command: "hub codex-spoke",
|
|
37
|
+
label: "io.github.huynle.hub-codex-spoke",
|
|
38
|
+
unit: "hub-codex-spoke.service",
|
|
39
|
+
log: "codex-spoke.log",
|
|
40
|
+
// HUB_CODEX_CONFIG (one override per line) is not carried: a unit's Environment= cannot hold several lines.
|
|
41
|
+
carried: [...HUB_PLACES, "CODEX_HOME", "HUB_CODEX_BIN", "HUB_CODEX_DAEMON_SOCKET"],
|
|
42
|
+
elsewhere: "Run `hub codex-spoke serve` under your own service manager (or let `hub codex` start it)",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const SPECS = { "claude-code": CLAUDE_CODE_SERVICE, "codex-spoke": CODEX_SPOKE_SERVICE } as const
|
|
46
|
+
|
|
47
|
+
/** The codex-spoke unit runs the Codex `hubctl setup` installed (HUB_CODEX_BIN), unless the shell names another. */
|
|
48
|
+
const codexEnvironment: ExtraEnvironment = (io): Record<string, string> => {
|
|
49
|
+
const bin = codexBinary(io.env, io.home)
|
|
50
|
+
return path.isAbsolute(bin) && !io.env.HUB_CODEX_BIN ? { HUB_CODEX_BIN: bin } : {}
|
|
51
|
+
}
|
|
52
|
+
const EXTRA: Partial<Record<keyof typeof SPECS, ExtraEnvironment>> = { "codex-spoke": codexEnvironment }
|
|
53
|
+
export type SpokeServiceName = keyof typeof SPECS
|
|
54
|
+
export const spokeServiceSpec = (name: SpokeServiceName): ServiceSpec => SPECS[name]
|
|
55
|
+
|
|
56
|
+
/** Why the installed service would not do anything yet (never the token). */
|
|
57
|
+
function readiness(env: Env, home: string): string[] {
|
|
58
|
+
return readSpokeCredentials(env, home) ? [] : ["this machine is not enrolled with a hub yet (no machine token in config.json): enroll it with `hubctl machine enroll --server <hub URL>`; until then the service exits and is restarted"]
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function installSpokeService(name: SpokeServiceName, io: NodeIo, options: { readonly dryRun: boolean }): NodeCommandOutput {
|
|
62
|
+
return installService(SPECS[name], io, options, readiness(io.env, io.home), EXTRA[name])
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function uninstallSpokeService(name: SpokeServiceName, io: NodeIo, options: { readonly dryRun: boolean }): NodeCommandOutput {
|
|
66
|
+
return uninstallService(SPECS[name], io, options)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface SpokeServiceStatus {
|
|
70
|
+
readonly service: ServiceState
|
|
71
|
+
readonly enrolled?: { readonly server: string; readonly machineID?: string }
|
|
72
|
+
/** The harness CLI as this shell finds it (the service looks on its own PATH, see the unit). */
|
|
73
|
+
readonly harness: { readonly bin: string; readonly found?: string }
|
|
74
|
+
/** claude-code: the daemon's socket (hooks look there). */
|
|
75
|
+
readonly socket?: string
|
|
76
|
+
/** codex-spoke: the running service's pid (its pid file), and the Codex daemon socket it attaches to. */
|
|
77
|
+
readonly running?: number
|
|
78
|
+
readonly daemonSocket?: string
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function spokeServiceStatus(name: SpokeServiceName, io: NodeIo): SpokeServiceStatus {
|
|
82
|
+
const credentials = readSpokeCredentials(io.env, io.home)
|
|
83
|
+
const bin = name === "claude-code" ? claudeBinary(io.env, io.home) : codexBinary(io.env, io.home)
|
|
84
|
+
const found = findExecutable(bin, io.env)
|
|
85
|
+
const base = {
|
|
86
|
+
service: serviceState(SPECS[name], io, EXTRA[name]),
|
|
87
|
+
...(credentials ? { enrolled: { server: credentials.server, ...(credentials.machineID ? { machineID: credentials.machineID } : {}) } } : {}),
|
|
88
|
+
harness: { bin, ...(found ? { found } : {}) },
|
|
89
|
+
}
|
|
90
|
+
if (name === "claude-code") return { ...base, socket: socketPath(io.env, io.home) }
|
|
91
|
+
const running = readServicePid(io.env, io.home)
|
|
92
|
+
const daemonSocket = daemonSocketPath(io.env, io.home)
|
|
93
|
+
return { ...base, ...(running !== undefined ? { running } : {}), ...(daemonSocket ? { daemonSocket } : {}) }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function spokeServiceStatusText(name: SpokeServiceName, status: SpokeServiceStatus): string {
|
|
97
|
+
const spec = SPECS[name]
|
|
98
|
+
const lines = [serviceStateLine(spec, status.service)]
|
|
99
|
+
lines.push(status.enrolled ? `machine: enrolled with ${status.enrolled.server}${status.enrolled.machineID ? ` as ${status.enrolled.machineID}` : ""}` : "machine: not enrolled (enroll it with `hubctl machine enroll --server <hub URL>`)")
|
|
100
|
+
const what = name === "claude-code" ? "Claude Code CLI" : "Codex CLI"
|
|
101
|
+
lines.push(status.harness.found ? `${what}: ${status.harness.found}` : `${what}: ${status.harness.bin} not found on this shell's PATH (no hub launches${name === "claude-code" ? "" : " of new threads"} until it is)`)
|
|
102
|
+
if (status.socket) lines.push(`daemon socket: ${status.socket}`)
|
|
103
|
+
if (name === "codex-spoke") {
|
|
104
|
+
lines.push(status.running !== undefined ? `running: pid ${status.running}` : "running: no")
|
|
105
|
+
lines.push(`Codex daemon socket: ${status.daemonSocket ?? "off"}`)
|
|
106
|
+
}
|
|
107
|
+
lines.push(...serviceCheckLines(status.service))
|
|
108
|
+
return lines.join("\n")
|
|
109
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// `hub watch`: GET /api/agent/watch (SSE, through the SDK) printed as JSON lines, one per event, until interrupted.
|
|
2
|
+
// Heartbeats are skipped (the SDK does). The hub closing the stream or the stream breaking is an error.
|
|
3
|
+
import { HubTransportError } from "../../sdk/src/index.ts"
|
|
4
|
+
import type { Credentials } from "./credentials"
|
|
5
|
+
import { CliError, cliError, clientFor, EXIT } from "./http"
|
|
6
|
+
|
|
7
|
+
/** One compact JSON line per event (non-JSON data is printed as is). */
|
|
8
|
+
const line = (data: string) => {
|
|
9
|
+
try {
|
|
10
|
+
return JSON.stringify(JSON.parse(data))
|
|
11
|
+
} catch {
|
|
12
|
+
return data
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const AGAIN = "Run `hub watch` again"
|
|
17
|
+
|
|
18
|
+
export async function watch(credentials: Credentials, options: { blocked?: boolean; session?: string }, write: (line: string) => void, signal?: AbortSignal): Promise<void> {
|
|
19
|
+
try {
|
|
20
|
+
for await (const event of clientFor(credentials).watch({ ...options, ...(signal ? { signal } : {}) })) write(`${line(event.data)}\n`)
|
|
21
|
+
} catch (cause) {
|
|
22
|
+
if (signal?.aborted) return
|
|
23
|
+
if (cause instanceof HubTransportError && cause.kind === "stream") throw new CliError(EXIT.other, cause.message, AGAIN)
|
|
24
|
+
throw cliError(cause, credentials)
|
|
25
|
+
}
|
|
26
|
+
if (signal?.aborted) return
|
|
27
|
+
throw new CliError(EXIT.other, "the watch stream ended (the hub closed it)", AGAIN)
|
|
28
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@huynle/hub-codex",
|
|
3
|
+
"version": "0.7.2",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "The Codex app-server spoke: attaches to Codex's app-server (its own over stdio, and the shared daemon's socket when present), and the `hub codex` wrapper (internal; shipped inside @huynle/hub, not published on its own).",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// One initialized connection to a Codex app-server: JSON-RPC over a channel, the initialize handshake (with the
|
|
2
|
+
// experimental API, which thread/inject_items and turn/steer's clientUserMessageId need) and the client's name.
|
|
3
|
+
import { JsonRpcPeer, type Channel, type PeerHandlers, type RpcID } from "./jsonrpc.ts"
|
|
4
|
+
import type { AppServerPort } from "./sessions.ts"
|
|
5
|
+
|
|
6
|
+
export interface AppServerConnection extends AppServerPort {
|
|
7
|
+
readonly peer: JsonRpcPeer
|
|
8
|
+
/** Resolves with the reason when the connection closed. */
|
|
9
|
+
readonly closed: Promise<string>
|
|
10
|
+
readonly isClosed: boolean
|
|
11
|
+
close(): void
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface OpenOptions extends PeerHandlers {
|
|
15
|
+
/** The connection's name (keeps its JSON-RPC request IDs apart from another connection's). */
|
|
16
|
+
readonly name: string
|
|
17
|
+
readonly clientVersion: string
|
|
18
|
+
readonly timeoutMs?: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const INIT_TIMEOUT_MS = 20_000
|
|
22
|
+
|
|
23
|
+
/** Opens the connection: initialize, then the initialized notification. Closes the channel when that fails. */
|
|
24
|
+
export async function openAppServer(channel: Channel, options: OpenOptions): Promise<AppServerConnection> {
|
|
25
|
+
const peer = new JsonRpcPeer(channel, options)
|
|
26
|
+
try {
|
|
27
|
+
await peer.request("initialize", { clientInfo: { name: "hub", title: "Hub", version: options.clientVersion }, capabilities: { experimentalApi: true } }, { timeoutMs: options.timeoutMs ?? INIT_TIMEOUT_MS })
|
|
28
|
+
} catch (cause) {
|
|
29
|
+
peer.close()
|
|
30
|
+
throw cause
|
|
31
|
+
}
|
|
32
|
+
peer.notify("initialized")
|
|
33
|
+
return {
|
|
34
|
+
name: options.name,
|
|
35
|
+
peer,
|
|
36
|
+
closed: peer.closed,
|
|
37
|
+
get isClosed() {
|
|
38
|
+
return peer.isClosed
|
|
39
|
+
},
|
|
40
|
+
request: (method: string, params?: unknown) => peer.request(method, params),
|
|
41
|
+
respond: (id: RpcID, result: unknown) => peer.respond(id, result),
|
|
42
|
+
close: () => peer.close(),
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Hub IDs for Codex's native IDs (decision fx04e42c point 2). A thread ID (UUIDv7) maps to the reversible
|
|
2
|
+
// ses_cx<32 hex> (spoke-sdk hubSessionID, namespace "cx"). Other IDs the hub sees (approval requests, items, turns,
|
|
3
|
+
// inbox entries) are plain IDs (protocol wire.ts ID).
|
|
4
|
+
import { randomBytes } from "node:crypto"
|
|
5
|
+
import { hubSessionID, nativeSessionID } from "../../spoke-sdk/src/index.ts"
|
|
6
|
+
|
|
7
|
+
/** The session namespace of Codex in hub session IDs. */
|
|
8
|
+
export const CODEX_NAMESPACE = "cx"
|
|
9
|
+
|
|
10
|
+
const PLAIN_MAX = 300
|
|
11
|
+
|
|
12
|
+
/** The hub session ID of a Codex thread. */
|
|
13
|
+
export const codexSessionID = (threadID: string): string => hubSessionID(CODEX_NAMESPACE, threadID)
|
|
14
|
+
|
|
15
|
+
/** The Codex thread a hub session ID stands for; undefined when it is not a Codex one. */
|
|
16
|
+
export const codexThreadID = (sessionID: string): string | undefined => nativeSessionID(CODEX_NAMESPACE, sessionID)
|
|
17
|
+
|
|
18
|
+
/** `raw` as a plain hub ID (letters, digits and _.:@/+~- only, at most 300 characters); a fresh one when empty. */
|
|
19
|
+
export function plainID(raw: string, prefix = "id"): string {
|
|
20
|
+
const clean = (typeof raw === "string" ? raw : "").replace(/[^A-Za-z0-9_.:@/+~-]/g, "_").slice(0, PLAIN_MAX)
|
|
21
|
+
return clean || `${prefix}_${randomBytes(8).toString("hex")}`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The hub's ID for an app-server request to clients (an approval): the connection's name and the JSON-RPC id, typed
|
|
26
|
+
* (n: number, s: string) so 0 and "0" stay apart. JSON-RPC ids are per connection.
|
|
27
|
+
*/
|
|
28
|
+
export const requestKey = (connection: string, id: number | string): string => plainID(`per_${connection}_${typeof id === "number" ? "n" : "s"}${id}`)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// @huynle/hub-codex: the Codex app-server spoke (design §1.2, §13.3; decision fx04e42c point 5 as corrected by the
|
|
2
|
+
// P7-T0 spike, report v0hsdqhm). The spoke attaches to Codex's app-server as one of its clients: its own
|
|
3
|
+
// `codex app-server` over stdio for the sessions it starts, and the shared daemon's socket when present (the TUI's
|
|
4
|
+
// threads live there). `hub codex` runs the Codex TUI so its threads land on the daemon.
|
|
5
|
+
export * from "./ids.ts"
|
|
6
|
+
export * from "./jsonrpc.ts"
|
|
7
|
+
export * from "./stdio.ts"
|
|
8
|
+
export * from "./websocket.ts"
|
|
9
|
+
export * from "./sessions.ts"
|
|
10
|
+
export * from "./app-server.ts"
|
|
11
|
+
export * from "./spoke.ts"
|