@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,473 @@
|
|
|
1
|
+
// The agent directory (design §2, §7): who is who among one user's sessions. Persisted per user in
|
|
2
|
+
// UserRecord.agent (agent-data.ts): session records, @names (with rename aliases) and @@role holders.
|
|
3
|
+
//
|
|
4
|
+
// Every rule is evaluated lazily against the clock when the directory is used (no timers):
|
|
5
|
+
// - A session record is created when a spoke reports the session; lastSeen is saved at minute granularity
|
|
6
|
+
// while it stays online (LAST_SEEN_STEP_MS), and exactly when it stops being reported.
|
|
7
|
+
// - Archive source: the console's Archive (viewPrefs.hiddenSessions). Spokes never report archived OpenCode
|
|
8
|
+
// sessions (the listing drops them), so SessionInfo.archived never reaches the hub. archivedAt is the first
|
|
9
|
+
// time the directory sees the session in that list; unarchiving clears it. deletedAt comes from markDeleted.
|
|
10
|
+
// - Names: derived once (names.ts deriveName), from the first non-placeholder title, for root sessions only; a taken derived name
|
|
11
|
+
// gets a suffix, an explicit taken one fails (name_taken). A name is released NAME_RELEASE_MS after its
|
|
12
|
+
// session was archived or deleted; a rename keeps the old name as an alias for ALIAS_MS.
|
|
13
|
+
// - Roles: see claimRole. A holder offline (not reported) for ROLE_OFFLINE_MS loses the role, except during
|
|
14
|
+
// the RECONNECT_WINDOW_MS after the hub started, while spokes reconnect.
|
|
15
|
+
// - At most SESSION_LIMIT records per user: archived/deleted ones are dropped first, then the least recently
|
|
16
|
+
// seen, with their names and roles.
|
|
17
|
+
// - Primer (design §2.3): a session record remembers when the identity primer reached it (primed); explicit
|
|
18
|
+
// names are announced to onNamed listeners (the delivery engine primes on them), derived names never are.
|
|
19
|
+
//
|
|
20
|
+
// Tenancy: everything is per user; another user's sessions, names and roles do not exist here (not_found),
|
|
21
|
+
// and ambiguity candidates only ever list the caller's own sessions.
|
|
22
|
+
// Reads (resolve, who, primed) are synchronous: they evaluate the rules on a copy of the stored data and save
|
|
23
|
+
// nothing. Writes (sync, names, roles, primer and deletion marks) are async: they run inside one store update and
|
|
24
|
+
// resolve once it is saved (their errors reject). Uniqueness holds across pods: on a shared (cluster) store an update
|
|
25
|
+
// that lost a race with another pod's write runs again on the fresh data before it resolves.
|
|
26
|
+
// Cluster mode: the SessionSource reports every pod's sessions (the Registry's cluster view), so a sync on one pod
|
|
27
|
+
// never takes another pod's sessions for offline.
|
|
28
|
+
import { roleKey, type AgentData, type AgentName, type AgentRole, type AgentSession } from "../agent-data"
|
|
29
|
+
import type { Store } from "../store"
|
|
30
|
+
import { AgentError } from "./errors"
|
|
31
|
+
import { deriveName, isPlaceholderTitle, isReservedName, isReservedRole, parseAddress, validName, withSuffix, type Address } from "./names"
|
|
32
|
+
|
|
33
|
+
const MINUTE = 60_000
|
|
34
|
+
const HOUR = 60 * MINUTE
|
|
35
|
+
const DAY = 24 * HOUR
|
|
36
|
+
|
|
37
|
+
export const SESSION_LIMIT = 2000
|
|
38
|
+
export const NAME_RELEASE_MS = 7 * DAY
|
|
39
|
+
export const ALIAS_MS = 24 * HOUR
|
|
40
|
+
export const ROLE_OFFLINE_MS = 30 * MINUTE
|
|
41
|
+
export const RECONNECT_WINDOW_MS = 2 * MINUTE
|
|
42
|
+
export const LAST_SEEN_STEP_MS = MINUTE
|
|
43
|
+
/** Most candidates listed in an ambiguous error. */
|
|
44
|
+
const CANDIDATES_MAX = 20
|
|
45
|
+
|
|
46
|
+
/** A session a spoke of the user reports now. */
|
|
47
|
+
export interface ReportedSession {
|
|
48
|
+
readonly sessionID: string
|
|
49
|
+
readonly title: string
|
|
50
|
+
readonly machineID?: string
|
|
51
|
+
readonly parentID?: string
|
|
52
|
+
/** Project name, lowercased (the `.project` scope). */
|
|
53
|
+
readonly project?: string
|
|
54
|
+
readonly state?: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** What the directory needs to know about the user's live sessions. */
|
|
58
|
+
export interface SessionSource {
|
|
59
|
+
/** The user's sessions reported now (roots and subagents), only ones the user owns. */
|
|
60
|
+
reported(userID: string): readonly ReportedSession[]
|
|
61
|
+
/** IDs of the sessions the user archived. */
|
|
62
|
+
archived(userID: string): ReadonlySet<string>
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type DirectoryStore = Pick<Store, "updateAgentData" | "agentData" | "user">
|
|
66
|
+
|
|
67
|
+
export interface DirectoryOptions {
|
|
68
|
+
readonly store: DirectoryStore
|
|
69
|
+
readonly source: SessionSource
|
|
70
|
+
readonly now?: () => number
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface RoleView {
|
|
74
|
+
readonly role: string
|
|
75
|
+
readonly project?: string
|
|
76
|
+
readonly generation: number
|
|
77
|
+
readonly exclusive?: boolean
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** One session in directory answers. */
|
|
81
|
+
export interface DirectoryEntry {
|
|
82
|
+
readonly sessionID: string
|
|
83
|
+
readonly name?: string
|
|
84
|
+
readonly aliases?: string[]
|
|
85
|
+
readonly machineID?: string
|
|
86
|
+
readonly parentID?: string
|
|
87
|
+
readonly project?: string
|
|
88
|
+
readonly title?: string
|
|
89
|
+
readonly state?: string
|
|
90
|
+
/** A spoke reports it now. */
|
|
91
|
+
readonly online: boolean
|
|
92
|
+
readonly lastSeen?: number
|
|
93
|
+
readonly archived?: boolean
|
|
94
|
+
readonly roles: RoleView[]
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Hears an explicit name change (setName); derived names are never announced. */
|
|
98
|
+
export type NamedListener = (userID: string, sessionID: string, name: string) => void
|
|
99
|
+
|
|
100
|
+
export interface Resolved {
|
|
101
|
+
readonly address: Address
|
|
102
|
+
/** One session for an ID or @name; every live holder for a @@role. */
|
|
103
|
+
readonly sessions: DirectoryEntry[]
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The working copy of one user's data inside an update. */
|
|
107
|
+
interface Draft {
|
|
108
|
+
readonly data: AgentData
|
|
109
|
+
readonly sessions: AgentSession[]
|
|
110
|
+
readonly byID: Map<string, AgentSession>
|
|
111
|
+
readonly reported: Map<string, ReportedSession>
|
|
112
|
+
names: AgentName[]
|
|
113
|
+
roles: AgentRole[]
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const ended = (s: AgentSession | undefined): number | undefined => {
|
|
117
|
+
const times = [s?.archivedAt, s?.deletedAt].filter((t): t is number => typeof t === "number")
|
|
118
|
+
return times.length ? Math.min(...times) : undefined
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export class Directory {
|
|
122
|
+
private readonly store: DirectoryStore
|
|
123
|
+
private readonly source: SessionSource
|
|
124
|
+
private readonly now: () => number
|
|
125
|
+
private readonly startedAt: number
|
|
126
|
+
/** Per user: the sessions reported at the last update (to stamp lastSeen when they go away). */
|
|
127
|
+
private readonly online = new Map<string, Set<string>>()
|
|
128
|
+
private readonly named = new Set<NamedListener>()
|
|
129
|
+
|
|
130
|
+
constructor(options: DirectoryOptions) {
|
|
131
|
+
this.store = options.store
|
|
132
|
+
this.source = options.source
|
|
133
|
+
this.now = options.now ?? Date.now
|
|
134
|
+
this.startedAt = this.now()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Applies what the spokes report and the clock to the user's records (saves only on change). */
|
|
138
|
+
sync(userID: string): Promise<void> {
|
|
139
|
+
return this.update(userID, () => undefined)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Records that a session was deleted (its name is released NAME_RELEASE_MS later). */
|
|
143
|
+
markDeleted(userID: string, sessionID: string): Promise<void> {
|
|
144
|
+
return this.update(userID, (draft) => {
|
|
145
|
+
const record = draft.byID.get(sessionID)
|
|
146
|
+
if (record && record.deletedAt === undefined) record.deletedAt = this.now()
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ---------- primer ----------
|
|
151
|
+
|
|
152
|
+
/** Whether the identity primer reached the session (false for an unknown one). */
|
|
153
|
+
primed(userID: string, sessionID: string): boolean {
|
|
154
|
+
return this.read(userID, (draft) => draft.byID.get(sessionID)?.primed !== undefined)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Records that the identity primer reached the session (an unknown session is ignored). */
|
|
158
|
+
markPrimed(userID: string, sessionID: string): Promise<void> {
|
|
159
|
+
return this.update(userID, (draft) => {
|
|
160
|
+
const record = draft.byID.get(sessionID)
|
|
161
|
+
if (record && record.primed === undefined) record.primed = this.now()
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Listens for explicit names (setName); returns the unsubscribe. */
|
|
166
|
+
onNamed(listener: NamedListener): () => void {
|
|
167
|
+
this.named.add(listener)
|
|
168
|
+
return () => void this.named.delete(listener)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ---------- names ----------
|
|
172
|
+
|
|
173
|
+
/** Names a session (explicit: a taken name fails); the previous name stays an alias for ALIAS_MS. Tells onNamed listeners. */
|
|
174
|
+
async setName(userID: string, sessionID: string, requested: string): Promise<{ sessionID: string; name: string; alias?: string }> {
|
|
175
|
+
const result = await this.assignName(userID, sessionID, requested)
|
|
176
|
+
for (const listener of this.named) {
|
|
177
|
+
try {
|
|
178
|
+
listener(userID, sessionID, result.name)
|
|
179
|
+
} catch (cause) {
|
|
180
|
+
console.error("[hub] name listener failed:", cause)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return result
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private async assignName(userID: string, sessionID: string, requested: string): Promise<{ sessionID: string; name: string; alias?: string }> {
|
|
187
|
+
const name = requested.replace(/^@/, "")
|
|
188
|
+
if (!validName(name)) throw new AgentError("invalid", "A name is 2–40 characters: a-z, 0-9 and -")
|
|
189
|
+
if (isReservedName(name)) throw new AgentError("invalid", "@me is reserved: it always means the user")
|
|
190
|
+
return this.update(userID, (draft) => {
|
|
191
|
+
const record = this.known(draft, sessionID)
|
|
192
|
+
const held = draft.names.find((n) => n.name === name)
|
|
193
|
+
if (held && held.sessionID !== sessionID) throw new AgentError("name_taken", `@${name} is taken`)
|
|
194
|
+
record.named = true
|
|
195
|
+
const current = draft.names.find((n) => n.sessionID === sessionID && n.expiresAt === undefined)
|
|
196
|
+
if (current?.name === name) return { sessionID, name }
|
|
197
|
+
const t = this.now()
|
|
198
|
+
if (held) draft.names = draft.names.filter((n) => n !== held)
|
|
199
|
+
if (current) current.expiresAt = t + ALIAS_MS
|
|
200
|
+
draft.names.push({ name, sessionID, created: t })
|
|
201
|
+
return { sessionID, name, ...(current ? { alias: current.name } : {}) }
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ---------- roles ----------
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Claims `role` (or `role.project`) for a session. Each new holder gets the next holder generation of
|
|
209
|
+
* (role, project). An exclusive claim fails (name_taken) while another live holder exists, and nobody else
|
|
210
|
+
* can claim while an exclusive holder is live. Reserved roles (orchestrator…) need `byUser`
|
|
211
|
+
* (grants with `roles` come in phase 3). Exclusivity and generations are per (role, project scope).
|
|
212
|
+
*/
|
|
213
|
+
async claimRole(userID: string, sessionID: string, requested: string, options: { exclusive?: boolean; project?: string; byUser?: boolean } = {}): Promise<RoleView> {
|
|
214
|
+
const { role, project } = parseRole(requested, options.project)
|
|
215
|
+
if (isReservedRole(role) && !options.byUser) throw new AgentError("forbidden", `@@${role} is reserved: only the user can assign it`)
|
|
216
|
+
return this.update(userID, (draft) => {
|
|
217
|
+
this.known(draft, sessionID)
|
|
218
|
+
const same = draft.roles.filter((r) => r.role === role && r.project === project)
|
|
219
|
+
const mine = same.find((r) => r.sessionID === sessionID)
|
|
220
|
+
const others = same.filter((r) => r !== mine)
|
|
221
|
+
if (others.some((r) => r.exclusive) || (options.exclusive && others.length)) {
|
|
222
|
+
throw new AgentError("name_taken", `@@${scoped(role, project)} is held${others.some((r) => r.exclusive) ? " exclusively" : ""} by another session`)
|
|
223
|
+
}
|
|
224
|
+
if (mine) {
|
|
225
|
+
if (options.exclusive) mine.exclusive = true
|
|
226
|
+
return roleView(mine)
|
|
227
|
+
}
|
|
228
|
+
const key = roleKey(role, project)
|
|
229
|
+
const generations = { ...(draft.data.generations ?? {}) }
|
|
230
|
+
const generation = (generations[key] ?? 0) + 1
|
|
231
|
+
generations[key] = generation
|
|
232
|
+
draft.data.generations = generations
|
|
233
|
+
const claim: AgentRole = { role, sessionID, ...(project !== undefined ? { project } : {}), generation, ...(options.exclusive ? { exclusive: true } : {}), claimedAt: this.now() }
|
|
234
|
+
draft.roles.push(claim)
|
|
235
|
+
return roleView(claim)
|
|
236
|
+
})
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Gives a role up; not_found when the session does not hold it. */
|
|
240
|
+
async releaseRole(userID: string, sessionID: string, requested: string, scope?: string): Promise<{ role: string; project?: string }> {
|
|
241
|
+
const { role, project } = parseRole(requested, scope)
|
|
242
|
+
return this.update(userID, (draft) => {
|
|
243
|
+
const held = draft.roles.find((r) => r.sessionID === sessionID && r.role === role && r.project === project)
|
|
244
|
+
if (!held) throw new AgentError("not_found", `Not holding @@${scoped(role, project)}`)
|
|
245
|
+
draft.roles = draft.roles.filter((r) => r !== held)
|
|
246
|
+
return project !== undefined ? { role, project } : { role }
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ---------- directory ----------
|
|
251
|
+
|
|
252
|
+
/** The user's named, role-holding or online sessions (not archived or deleted), by project and role. */
|
|
253
|
+
who(userID: string, filter: { project?: string; role?: string } = {}): DirectoryEntry[] {
|
|
254
|
+
const project = filter.project?.toLowerCase()
|
|
255
|
+
const role = filter.role !== undefined ? parseRole(filter.role, project) : undefined
|
|
256
|
+
return this.read(userID, (draft) =>
|
|
257
|
+
draft.sessions
|
|
258
|
+
.filter((s) => s.archivedAt === undefined && s.deletedAt === undefined)
|
|
259
|
+
.map((s) => this.entry(draft, s.sessionID))
|
|
260
|
+
.filter((e) => e.online || e.name !== undefined || e.roles.length > 0)
|
|
261
|
+
.filter((e) => project === undefined || e.project === project || e.roles.some((r) => r.project === project))
|
|
262
|
+
.filter((e) => !role || e.roles.some((r) => r.role === role.role && inScope(r.project, e.project, role.project))),
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ---------- resolution ----------
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* The sessions an address names, for this user only: `ses_…` (an ID or a unique ID prefix), `@name[.project]`
|
|
270
|
+
* or `@@role[.project]` (every live holder). Throws invalid (malformed), not_found or ambiguous {candidates}.
|
|
271
|
+
*/
|
|
272
|
+
resolve(userID: string, addr: string): Resolved {
|
|
273
|
+
const address = parseAddress(addr)
|
|
274
|
+
if (!address) throw new AgentError("invalid", `Not an address: use ses_…, @name or @@role`)
|
|
275
|
+
return this.read(userID, (draft) => ({ address, sessions: this.lookup(draft, address, addr) }))
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Exactly one session; several (a role with several holders) is ambiguous. */
|
|
279
|
+
resolveOne(userID: string, addr: string): DirectoryEntry {
|
|
280
|
+
const { sessions } = this.resolve(userID, addr)
|
|
281
|
+
if (sessions.length > 1) throw ambiguous(addr, sessions)
|
|
282
|
+
return sessions[0]!
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private lookup(draft: Draft, address: Address, addr: string): DirectoryEntry[] {
|
|
286
|
+
const missing = () => new AgentError("not_found", `No session ${addr}`)
|
|
287
|
+
if (address.kind === "id") {
|
|
288
|
+
if (draft.byID.has(address.id)) return [this.entry(draft, address.id)]
|
|
289
|
+
const matches = draft.sessions.filter((s) => s.sessionID.startsWith(address.id)).map((s) => this.entry(draft, s.sessionID))
|
|
290
|
+
if (matches.length > 1) throw ambiguous(addr, matches)
|
|
291
|
+
if (!matches.length) throw missing()
|
|
292
|
+
return matches
|
|
293
|
+
}
|
|
294
|
+
if (address.kind === "name") {
|
|
295
|
+
const held = draft.names.find((n) => n.name === address.name)
|
|
296
|
+
const entry = held && this.entry(draft, held.sessionID)
|
|
297
|
+
if (!entry || (address.project !== undefined && entry.project !== address.project)) throw missing()
|
|
298
|
+
return [entry]
|
|
299
|
+
}
|
|
300
|
+
const holders = draft.roles.filter((r) => r.role === address.role && inScope(r.project, draft.byID.get(r.sessionID)?.project, address.project))
|
|
301
|
+
const ids = [...new Set(holders.map((r) => r.sessionID))]
|
|
302
|
+
if (!ids.length) throw missing()
|
|
303
|
+
return ids.map((id) => this.entry(draft, id))
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ---------- internals ----------
|
|
307
|
+
|
|
308
|
+
private known(draft: Draft, sessionID: string): AgentSession {
|
|
309
|
+
const record = draft.byID.get(sessionID)
|
|
310
|
+
if (!record) throw new AgentError("not_found", `No session ${sessionID}`)
|
|
311
|
+
return record
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
private entry(draft: Draft, sessionID: string): DirectoryEntry {
|
|
315
|
+
const record = draft.byID.get(sessionID)
|
|
316
|
+
const live = draft.reported.get(sessionID)
|
|
317
|
+
const own = draft.names.filter((n) => n.sessionID === sessionID)
|
|
318
|
+
const name = own.find((n) => n.expiresAt === undefined)?.name
|
|
319
|
+
const aliases = own.filter((n) => n.expiresAt !== undefined).map((n) => n.name)
|
|
320
|
+
const roles = draft.roles.filter((r) => r.sessionID === sessionID).map(roleView)
|
|
321
|
+
return {
|
|
322
|
+
sessionID,
|
|
323
|
+
...(name ? { name } : {}),
|
|
324
|
+
...(aliases.length ? { aliases } : {}),
|
|
325
|
+
...(record?.machineID ? { machineID: record.machineID } : {}),
|
|
326
|
+
...(record?.parentID ? { parentID: record.parentID } : {}),
|
|
327
|
+
...(record?.project ? { project: record.project } : {}),
|
|
328
|
+
...(record?.title ? { title: record.title } : {}),
|
|
329
|
+
...(live?.state ? { state: live.state } : {}),
|
|
330
|
+
online: live !== undefined,
|
|
331
|
+
...(record?.lastSeen !== undefined ? { lastSeen: record.lastSeen } : {}),
|
|
332
|
+
...(record?.archivedAt !== undefined ? { archived: true } : {}),
|
|
333
|
+
roles,
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Runs `look` on a refreshed copy of the user's data, saving nothing; not_found for an unknown user. */
|
|
338
|
+
private read<T>(userID: string, look: (draft: Draft) => T): T {
|
|
339
|
+
if (!this.store.user(userID)) throw new AgentError("not_found", "No such user")
|
|
340
|
+
const reported = new Map(this.source.reported(userID).map((s) => [s.sessionID, s]))
|
|
341
|
+
return look(this.refresh(userID, this.store.agentData(userID), reported, this.source.archived(userID)))
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Runs `change` on the user's refreshed data in one store update and resolves once it is saved; not_found for an
|
|
346
|
+
* unknown user. On a shared store the change may run again (on fresh data, after another pod's write).
|
|
347
|
+
*/
|
|
348
|
+
private async update<T>(userID: string, change: (draft: Draft) => T): Promise<T> {
|
|
349
|
+
const reported = new Map(this.source.reported(userID).map((s) => [s.sessionID, s]))
|
|
350
|
+
const archived = this.source.archived(userID)
|
|
351
|
+
let done = false
|
|
352
|
+
let result: T | undefined
|
|
353
|
+
await this.store.updateAgentData(userID, (data) => {
|
|
354
|
+
const draft = this.refresh(userID, data, reported, archived)
|
|
355
|
+
result = change(draft)
|
|
356
|
+
this.write(draft)
|
|
357
|
+
done = true
|
|
358
|
+
})
|
|
359
|
+
if (!done) throw new AgentError("not_found", "No such user")
|
|
360
|
+
this.online.set(userID, new Set(reported.keys()))
|
|
361
|
+
return result as T
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
private write(draft: Draft): void {
|
|
365
|
+
const { data } = draft
|
|
366
|
+
if (draft.sessions.length || data.sessions) data.sessions = draft.sessions
|
|
367
|
+
if (draft.names.length || data.names) data.names = draft.names
|
|
368
|
+
if (draft.roles.length || data.roles) data.roles = draft.roles
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Records, archive marks, releases, derivations and the cap, as of now. */
|
|
372
|
+
private refresh(userID: string, data: AgentData, reported: Map<string, ReportedSession>, archived: ReadonlySet<string>): Draft {
|
|
373
|
+
const t = this.now()
|
|
374
|
+
const sessions = data.sessions ?? []
|
|
375
|
+
const byID = new Map(sessions.map((s) => [s.sessionID, s]))
|
|
376
|
+
for (const live of reported.values()) {
|
|
377
|
+
let record = byID.get(live.sessionID)
|
|
378
|
+
if (!record) {
|
|
379
|
+
record = { sessionID: live.sessionID, firstSeen: t }
|
|
380
|
+
sessions.push(record)
|
|
381
|
+
byID.set(live.sessionID, record)
|
|
382
|
+
}
|
|
383
|
+
for (const field of ["machineID", "parentID", "project", "title"] as const) {
|
|
384
|
+
const value = live[field]
|
|
385
|
+
if (value !== undefined && value !== "" && record[field] !== value) record[field] = value
|
|
386
|
+
}
|
|
387
|
+
if (record.lastSeen === undefined || t - record.lastSeen >= LAST_SEEN_STEP_MS) record.lastSeen = t
|
|
388
|
+
delete record.deletedAt
|
|
389
|
+
}
|
|
390
|
+
for (const id of this.online.get(userID) ?? []) {
|
|
391
|
+
const record = byID.get(id)
|
|
392
|
+
if (record && !reported.has(id)) record.lastSeen = t
|
|
393
|
+
}
|
|
394
|
+
for (const record of sessions) {
|
|
395
|
+
if (archived.has(record.sessionID)) record.archivedAt ??= t
|
|
396
|
+
else delete record.archivedAt
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const draft: Draft = { data, sessions, byID, reported, names: data.names ?? [], roles: data.roles ?? [] }
|
|
400
|
+
draft.names = draft.names.filter((n) => !this.nameReleased(n, byID, t))
|
|
401
|
+
draft.roles = draft.roles.filter((r) => this.holding(draft, r.sessionID, t))
|
|
402
|
+
this.derive(draft, t)
|
|
403
|
+
this.cap(draft)
|
|
404
|
+
return draft
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
private nameReleased(name: AgentName, byID: Map<string, AgentSession>, t: number): boolean {
|
|
408
|
+
if (name.expiresAt !== undefined && t >= name.expiresAt) return true
|
|
409
|
+
const end = ended(byID.get(name.sessionID))
|
|
410
|
+
return end !== undefined && t - end >= NAME_RELEASE_MS
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** A holder keeps its roles while reported, or offline for less than ROLE_OFFLINE_MS, or (any) during the reconnect window; never once deleted. */
|
|
414
|
+
private holding(draft: Draft, sessionID: string, t: number): boolean {
|
|
415
|
+
const record = draft.byID.get(sessionID)
|
|
416
|
+
if (record?.deletedAt !== undefined) return false
|
|
417
|
+
if (draft.reported.has(sessionID) || t - this.startedAt < RECONNECT_WINDOW_MS) return true
|
|
418
|
+
return record?.lastSeen !== undefined && t - record.lastSeen < ROLE_OFFLINE_MS
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Names root sessions once, from their first non-placeholder title. */
|
|
422
|
+
private derive(draft: Draft, t: number): void {
|
|
423
|
+
for (const live of draft.reported.values()) {
|
|
424
|
+
const record = draft.byID.get(live.sessionID)!
|
|
425
|
+
if (live.parentID || record.named || isPlaceholderTitle(live.title)) continue
|
|
426
|
+
record.named = true
|
|
427
|
+
const slug = deriveName(live.title)
|
|
428
|
+
if (!slug || draft.names.some((n) => n.sessionID === live.sessionID && n.expiresAt === undefined)) continue
|
|
429
|
+
const taken = new Set(draft.names.map((n) => n.name))
|
|
430
|
+
draft.names.push({ name: withSuffix(slug, (n) => taken.has(n) || isReservedName(n)), sessionID: live.sessionID, created: t })
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** Drops records beyond SESSION_LIMIT (never a reported one), with their names and roles. */
|
|
435
|
+
private cap(draft: Draft): void {
|
|
436
|
+
const excess = draft.sessions.length - SESSION_LIMIT
|
|
437
|
+
if (excess <= 0) return
|
|
438
|
+
const seen = (s: AgentSession) => s.lastSeen ?? s.firstSeen ?? 0
|
|
439
|
+
const victims = draft.sessions
|
|
440
|
+
.filter((s) => !draft.reported.has(s.sessionID))
|
|
441
|
+
.sort((a, b) => Number(ended(b) !== undefined) - Number(ended(a) !== undefined) || seen(a) - seen(b))
|
|
442
|
+
.slice(0, excess)
|
|
443
|
+
const gone = new Set(victims.map((s) => s.sessionID))
|
|
444
|
+
const kept = draft.sessions.filter((s) => !gone.has(s.sessionID))
|
|
445
|
+
draft.sessions.splice(0, draft.sessions.length, ...kept)
|
|
446
|
+
for (const id of gone) draft.byID.delete(id)
|
|
447
|
+
draft.names = draft.names.filter((n) => !gone.has(n.sessionID))
|
|
448
|
+
draft.roles = draft.roles.filter((r) => !gone.has(r.sessionID))
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** `role`, `@@role` or `role.project` (an explicit scope wins); invalid when malformed. */
|
|
453
|
+
function parseRole(requested: string, scope?: string): { role: string; project?: string } {
|
|
454
|
+
const [role = "", inline, ...rest] = requested.replace(/^@@/, "").split(".")
|
|
455
|
+
const project = (scope ?? inline)?.toLowerCase()
|
|
456
|
+
if (!validName(role) || rest.length || (project !== undefined && !/^[^./\s]+$/.test(project))) {
|
|
457
|
+
throw new AgentError("invalid", "A role is 2–40 characters: a-z, 0-9 and -, optionally .project")
|
|
458
|
+
}
|
|
459
|
+
return project !== undefined ? { role, project } : { role }
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const scoped = (role: string, project?: string) => (project !== undefined ? `${role}.${project}` : role)
|
|
463
|
+
|
|
464
|
+
/** A claim scoped to `claimProject` (or unscoped, in a session of `sessionProject`) counts for `wanted`. */
|
|
465
|
+
const inScope = (claimProject: string | undefined, sessionProject: string | undefined, wanted: string | undefined) =>
|
|
466
|
+
wanted === undefined || claimProject === wanted || (claimProject === undefined && sessionProject === wanted)
|
|
467
|
+
|
|
468
|
+
const roleView = (r: AgentRole): RoleView => ({ role: r.role, ...(r.project !== undefined ? { project: r.project } : {}), generation: r.generation ?? 0, ...(r.exclusive ? { exclusive: true } : {}) })
|
|
469
|
+
|
|
470
|
+
function ambiguous(addr: string, sessions: readonly DirectoryEntry[]): AgentError {
|
|
471
|
+
const candidates = sessions.slice(0, CANDIDATES_MAX).map((s) => ({ sessionID: s.sessionID, ...(s.name ? { name: s.name } : {}), ...(s.project ? { project: s.project } : {}) }))
|
|
472
|
+
return new AgentError("ambiguous", `${addr} matches ${sessions.length} sessions`, { extra: { candidates } })
|
|
473
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// The message envelope (design §3.3): the pure renderer and sanitiser live in the SDK (packages/sdk/src/envelope.ts,
|
|
2
|
+
// shared with the plugin's hub tool and `hub mcp`) and are re-exported here; the hub adds the sender derived only
|
|
3
|
+
// from the authenticated actor (partyFromActor) and the party keys of its indexes.
|
|
4
|
+
import type { Party } from "../../../packages/sdk/src/types.ts"
|
|
5
|
+
import { sanitizeLabel } from "../../../packages/sdk/src/envelope.ts"
|
|
6
|
+
import type { AgentActor } from "./actor"
|
|
7
|
+
|
|
8
|
+
export { LABEL_MAX, partyLabel, renderEnvelope, renderPrimer, sanitizeLabel, sanitizeText, type EnvelopeInput, type EnvelopeKind } from "../../../packages/sdk/src/envelope.ts"
|
|
9
|
+
export type { Party } from "../../../packages/sdk/src/types.ts"
|
|
10
|
+
|
|
11
|
+
/** Directory facts for a session sender's label (never taken from a request body). */
|
|
12
|
+
export interface PartyInfo {
|
|
13
|
+
readonly name?: string
|
|
14
|
+
readonly harness?: string
|
|
15
|
+
readonly machine?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The sender of a request, from its actor; session labels come from the hub's directory, sanitised. */
|
|
19
|
+
export function partyFromActor(actor: AgentActor, info: PartyInfo = {}): Party {
|
|
20
|
+
switch (actor.kind) {
|
|
21
|
+
case "user":
|
|
22
|
+
return { kind: "user" }
|
|
23
|
+
case "app":
|
|
24
|
+
return { kind: "app", tokenID: actor.tokenID, ...labels({ label: actor.label }) }
|
|
25
|
+
case "session":
|
|
26
|
+
return { kind: "session", sessionID: actor.sessionID, ...labels({ name: info.name, harness: info.harness, machine: info.machine }) }
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The given labels, sanitised, without empty ones. */
|
|
31
|
+
function labels<T extends Record<string, string | undefined>>(values: T): Partial<Record<keyof T, string>> {
|
|
32
|
+
const out: Partial<Record<keyof T, string>> = {}
|
|
33
|
+
for (const [key, value] of Object.entries(values) as [keyof T, string | undefined][]) {
|
|
34
|
+
const clean = value === undefined ? "" : sanitizeLabel(value)
|
|
35
|
+
if (clean) out[key] = clean
|
|
36
|
+
}
|
|
37
|
+
return out
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A stable key per party (index and mute keys): `ses:<id>`, `user`, `app:<tokenID>`. */
|
|
41
|
+
export function partyKey(party: Party): string {
|
|
42
|
+
switch (party.kind) {
|
|
43
|
+
case "session":
|
|
44
|
+
return `ses:${party.sessionID}`
|
|
45
|
+
case "user":
|
|
46
|
+
return "user"
|
|
47
|
+
case "app":
|
|
48
|
+
return `app:${party.tokenID}`
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// The /api/agent error envelope (design §8, §14.5): {error: {code, message, hint?, ...extra}}. Only /api/agent/*
|
|
2
|
+
// answers this way; the console routes keep their {error: string, code?} envelope (the web app reads it).
|
|
3
|
+
|
|
4
|
+
/** HTTP status of each agent error code. */
|
|
5
|
+
export const AGENT_ERROR_STATUS = {
|
|
6
|
+
/** Malformed request (bad JSON, bad fields, cookie and bearer together). */
|
|
7
|
+
invalid: 400,
|
|
8
|
+
/** An events cursor this hub did not give this user, or cannot read (start again without one). */
|
|
9
|
+
invalid_cursor: 400,
|
|
10
|
+
/** No credential, or an unknown, revoked or disabled one. */
|
|
11
|
+
unauthorized: 401,
|
|
12
|
+
/** A session or app token that expired: get a new one. */
|
|
13
|
+
token_expired: 401,
|
|
14
|
+
/** Outside the grant; a recommendation was recorded for the user. */
|
|
15
|
+
needs_user: 403,
|
|
16
|
+
/** No grant or scope, or a credential not admitted on this route. */
|
|
17
|
+
forbidden: 403,
|
|
18
|
+
/** No such thing, or another user's (they answer alike). */
|
|
19
|
+
not_found: 404,
|
|
20
|
+
/** The address matches several sessions (`candidates`). */
|
|
21
|
+
ambiguous: 409,
|
|
22
|
+
/** Explicit name or exclusive role already held. */
|
|
23
|
+
name_taken: 409,
|
|
24
|
+
/** Already answered, expired or ended. */
|
|
25
|
+
gone: 410,
|
|
26
|
+
/** The spoke or node lacks the feature or runtime (`feature`, `harness`). */
|
|
27
|
+
unsupported: 422,
|
|
28
|
+
/** A kill switch is on. */
|
|
29
|
+
paused: 423,
|
|
30
|
+
/** A pair, recipient, user or launch cap (`retryAfter`, seconds). */
|
|
31
|
+
rate_limited: 429,
|
|
32
|
+
/** An unexpected failure (details are logged, not sent). */
|
|
33
|
+
internal: 500,
|
|
34
|
+
/** The machine is offline (actions only; messages queue instead). */
|
|
35
|
+
offline: 503,
|
|
36
|
+
} as const satisfies Record<string, number>
|
|
37
|
+
|
|
38
|
+
export type AgentErrorCode = keyof typeof AGENT_ERROR_STATUS
|
|
39
|
+
|
|
40
|
+
export interface AgentErrorOptions {
|
|
41
|
+
readonly hint?: string
|
|
42
|
+
/** More fields in the envelope, e.g. candidates, retryAfter, feature; never code or message. */
|
|
43
|
+
readonly extra?: Record<string, unknown>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Throw from an agent handler; answered as the envelope with the code's status. */
|
|
47
|
+
export class AgentError extends Error {
|
|
48
|
+
constructor(
|
|
49
|
+
readonly code: AgentErrorCode,
|
|
50
|
+
message: string,
|
|
51
|
+
readonly options: AgentErrorOptions = {},
|
|
52
|
+
) {
|
|
53
|
+
super(message)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function agentError(error: AgentError): Response {
|
|
58
|
+
const { hint, extra = {} } = error.options
|
|
59
|
+
const headers: Record<string, string> = { "cache-control": "no-store" }
|
|
60
|
+
const retryAfter = extra.retryAfter
|
|
61
|
+
if (typeof retryAfter === "number" && Number.isFinite(retryAfter) && retryAfter >= 0) headers["retry-after"] = String(Math.ceil(retryAfter))
|
|
62
|
+
return Response.json(
|
|
63
|
+
{ error: { ...extra, code: error.code, message: error.message, ...(hint ? { hint } : {}) } },
|
|
64
|
+
{ status: AGENT_ERROR_STATUS[error.code], headers },
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The envelope for anything an agent handler threw. */
|
|
69
|
+
export function agentFailure(cause: unknown): Response {
|
|
70
|
+
if (cause instanceof AgentError) return agentError(cause)
|
|
71
|
+
if (cause instanceof SyntaxError) return agentError(new AgentError("invalid", "Invalid JSON"))
|
|
72
|
+
console.error("[hub] agent request failed:", cause)
|
|
73
|
+
return agentError(new AgentError("internal", "Internal error"))
|
|
74
|
+
}
|