@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,678 @@
|
|
|
1
|
+
// The delivery engine (design §3.3, §3.4; decisions jswt9p22, 52aqsopo): stores agent messages (./messages) and
|
|
2
|
+
// delivers them to their recipient sessions through the spokes. No routes: the message routes (P2-T6) call it.
|
|
3
|
+
//
|
|
4
|
+
// - Owner selection: the user's connections that report the session (Registry.sessionTargets, owner first) and
|
|
5
|
+
// announce "deliver"; one that fails or answers malformed is skipped for the next (same messageID: OpenCode
|
|
6
|
+
// dedupes it, even across restarts). Only spokes without "deliver" report the session: unsupported
|
|
7
|
+
// {feature: "deliver"} and nothing is stored. No fallback through steer/turn.
|
|
8
|
+
// - Modes: plain msg → delivery "queue" (after the running turn), ask/reply/decline/urgent → "steer" (also the
|
|
9
|
+
// first msg after a primer to an idle session: the two then share one model call, quirk hzmcqwpj); wake
|
|
10
|
+
// unless wake:false, a subagent recipient, the hop cap (alerted once when crossed) or a spent wake budget.
|
|
11
|
+
// - Roles: one holder, the idle one (by real evidence) else the most recently active, else (all offline) the
|
|
12
|
+
// one seen last; the message records the holder generation. sendAll: every holder, a message each. A session
|
|
13
|
+
// never messages itself: it is left out of a role it holds, and an address naming only it is invalid.
|
|
14
|
+
// - Pending: no deliver-capable connection reports the recipient (offline) or every one failed → kept for
|
|
15
|
+
// PENDING_MS in the StateStore and delivered oldest first on the user's next sessions frame (onSessions). In a
|
|
16
|
+
// cluster (deps.locks) each (user, session) flush holds a lease (deliver:<user>:<session>) and re-reads the
|
|
17
|
+
// entry under it, so a spoke reconnecting to another pod meanwhile never gets a message twice.
|
|
18
|
+
// OpenCode holds admitted messages itself across forms, interrupts and restarts (P2-T0), so there is no
|
|
19
|
+
// "retained" retry loop on the hub.
|
|
20
|
+
// - Re-wake: an open ask to an idle recipient (Registry.idle: native or hooks evidence) is woken again after
|
|
21
|
+
// each REWAKE_BACKOFF_MS delay with a short reminder (its own id), then the user is alerted once. Busy,
|
|
22
|
+
// offline or pending recipients push the wait back. State lives in the StateStore (survives restarts); the
|
|
23
|
+
// loop looks at deps.users (the hub passes every stored user; it runs on the leader pod only, see
|
|
24
|
+
// ../cluster/leader.ts), else at the users seen since start (a send or a sessions frame of theirs).
|
|
25
|
+
// - Primer (design §2.3, decision jswt9p22 #7): once per session, a short identity note (renderPrimer) is
|
|
26
|
+
// admitted without waking (wake:false, steer, meta.kind "primer") just before the first message the session
|
|
27
|
+
// gets, or when it is explicitly named (Directory.onNamed; derived names never prime). Only through
|
|
28
|
+
// deliver-capable spokes: an offline session is primed when its pending message is flushed. The directory
|
|
29
|
+
// persists the primed flag on the session record; a primer no spoke takes is tried again with the next
|
|
30
|
+
// message and never holds the message back.
|
|
31
|
+
// - @me (design §12.1): the user's address, never a session (even one named "me"). A msg or ask to @me is stored
|
|
32
|
+
// for the user (their stored-only inbox; nothing goes to a spoke); a msg alerts the user ("user"), an ask is a
|
|
33
|
+
// Decision's carrier (./decisions pushes it). The user cannot message @me; `@me.project` is malformed.
|
|
34
|
+
// - Loop protection (./message-limits): pair/user rate limits before storing, hop per message, human turns
|
|
35
|
+
// (console, voice, typed in the harness) reset a session's chain (humanTurn).
|
|
36
|
+
import type { DeliverRequest, DeliverResult, DeliveryMode, MessageKind as WireKind } from "../../protocol"
|
|
37
|
+
import { isDeliverResult } from "../../protocol"
|
|
38
|
+
import { randomBytes } from "node:crypto"
|
|
39
|
+
import type { Clock, Counters, Locks, StateStore, Timers } from "../backend/types"
|
|
40
|
+
import type { AgentActor } from "./actor"
|
|
41
|
+
import type { DirectoryEntry, NamedListener, Resolved } from "./directory"
|
|
42
|
+
import { type Party, type PartyInfo, partyFromActor, partyKey, partyLabel, renderEnvelope, renderPrimer } from "./envelope"
|
|
43
|
+
import { AgentError } from "./errors"
|
|
44
|
+
import { HOP_CAP, HopTracker, MessageLimiter, type MessageLimits, overHopCap } from "./message-limits"
|
|
45
|
+
import { parseAddress } from "./names"
|
|
46
|
+
import { type Delivery as DeliveryState, type Message, Messages, newMessageID } from "./messages"
|
|
47
|
+
|
|
48
|
+
/** What the engine reads from the Registry. */
|
|
49
|
+
export interface DeliveryRegistry {
|
|
50
|
+
/** Connections that report the session, the owner first (Registry.sessionTargets). */
|
|
51
|
+
sessionTargets(sessionID: string): string[]
|
|
52
|
+
supports(connID: string, feature: "deliver"): boolean
|
|
53
|
+
connTenant(connID: string): string | undefined
|
|
54
|
+
/** Idle by real evidence (Registry.idle). */
|
|
55
|
+
idle(sessionID: string): boolean
|
|
56
|
+
/** Higher: more recent activity. */
|
|
57
|
+
recency(sessionID: string): number
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Address resolution (Directory.resolve) and the primer flag (without primed/markPrimed: no primer). */
|
|
61
|
+
export interface DeliveryDirectory {
|
|
62
|
+
resolve(userID: string, addr: string): Resolved
|
|
63
|
+
primed?(userID: string, sessionID: string): boolean
|
|
64
|
+
markPrimed?(userID: string, sessionID: string): void | Promise<void>
|
|
65
|
+
/** Explicit names (Directory.onNamed): an unprimed session is primed then. */
|
|
66
|
+
onNamed?(listener: NamedListener): () => void
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Hub → spoke deliver request (SpokeHub.request). */
|
|
70
|
+
export type DeliverSend = (connID: string, req: DeliverRequest) => Promise<DeliverResult>
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Something the user should know: a reply chain crossed the hop cap (its messages no longer wake anyone), or an
|
|
74
|
+
* ask stayed unanswered by an idle recipient after every re-wake.
|
|
75
|
+
*/
|
|
76
|
+
export interface DeliveryAlert {
|
|
77
|
+
/** user: a session or app sent the user (@me) a message. */
|
|
78
|
+
readonly kind: "hopCap" | "unanswered" | "user"
|
|
79
|
+
/** The recipient session (user: the sending session, "" for an app). */
|
|
80
|
+
readonly sessionID: string
|
|
81
|
+
readonly messageID: string
|
|
82
|
+
readonly text: string
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface DeliveryDeps {
|
|
86
|
+
readonly state: StateStore
|
|
87
|
+
readonly counters: Counters
|
|
88
|
+
readonly registry: DeliveryRegistry
|
|
89
|
+
readonly directory: DeliveryDirectory
|
|
90
|
+
readonly send: DeliverSend
|
|
91
|
+
readonly now?: Clock
|
|
92
|
+
readonly timers?: Timers
|
|
93
|
+
readonly newID?: () => string
|
|
94
|
+
/** Labels of a session sender (default: its @name from the directory). */
|
|
95
|
+
readonly describe?: (userID: string, sessionID: string) => PartyInfo
|
|
96
|
+
readonly alert?: (userID: string, alert: DeliveryAlert) => void
|
|
97
|
+
/** Limiter caps (default PAIR_LIMIT, USER_LIMIT, WAKE_BUDGET). */
|
|
98
|
+
readonly limits?: Partial<MessageLimits>
|
|
99
|
+
/** The users the re-wake loop looks at (every user of the hub; default: the users seen by this process since start). */
|
|
100
|
+
readonly users?: () => Iterable<string> | Promise<Iterable<string>>
|
|
101
|
+
/** Cluster mode: a pending flush holds a lease per (user, session), so two pods never deliver the same message. */
|
|
102
|
+
readonly locks?: Locks
|
|
103
|
+
/** This process, as lease owner (default: a random one). */
|
|
104
|
+
readonly owner?: string
|
|
105
|
+
/** How often the re-wake loop runs (default REWAKE_TICK_MS). */
|
|
106
|
+
readonly tickMs?: number
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface SendOptions {
|
|
110
|
+
/** false: admit without starting a turn (reported queued). */
|
|
111
|
+
readonly wake?: boolean
|
|
112
|
+
/** Steer into a running turn, like an ask. */
|
|
113
|
+
readonly urgent?: boolean
|
|
114
|
+
/** Asks only. */
|
|
115
|
+
readonly expiresInMs?: number
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface Sent {
|
|
119
|
+
readonly message: Message
|
|
120
|
+
/** How it reached the recipient; undefined when the recipient is not a session (stored only). */
|
|
121
|
+
readonly delivery?: DeliveryState
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** A reply or decline as sent, with the message it answered. */
|
|
125
|
+
export interface Replied extends Sent {
|
|
126
|
+
readonly original: Message
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const defaultTimers: Timers = {
|
|
130
|
+
set: (fn, ms) => {
|
|
131
|
+
const handle = setTimeout(fn, ms)
|
|
132
|
+
;(handle as { unref?: () => void }).unref?.()
|
|
133
|
+
return handle
|
|
134
|
+
},
|
|
135
|
+
clear: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The user's own address (design §12.1). */
|
|
139
|
+
export const USER_ADDRESS = "@me"
|
|
140
|
+
|
|
141
|
+
/** True for `@me` (and the malformed `@me.project`, which send refuses). */
|
|
142
|
+
const isUserAddress = (addr: string): boolean => addr === USER_ADDRESS || addr.startsWith(`${USER_ADDRESS}.`)
|
|
143
|
+
|
|
144
|
+
/** How long an offline recipient's message waits for its session to come back. */
|
|
145
|
+
export const PENDING_MS = 24 * 60 * 60_000
|
|
146
|
+
|
|
147
|
+
/** Delays before each re-wake of an idle recipient with an open ask; one more after the last, then the user is told. */
|
|
148
|
+
export const REWAKE_BACKOFF_MS: readonly number[] = [5 * 60_000, 15 * 60_000, 45 * 60_000]
|
|
149
|
+
/** How often the re-wake loop looks for due asks. */
|
|
150
|
+
export const REWAKE_TICK_MS = 30_000
|
|
151
|
+
|
|
152
|
+
/** How long a pending flush may hold a (user, session) lease (it is released as soon as the flush is done). */
|
|
153
|
+
export const FLUSH_LEASE_MS = 120_000
|
|
154
|
+
|
|
155
|
+
/** StateStore collection: messageID → Pending. */
|
|
156
|
+
const PENDING = "message-pending"
|
|
157
|
+
|
|
158
|
+
/** StateStore collection: ask id → Rewake. */
|
|
159
|
+
const REWAKE = "message-rewake"
|
|
160
|
+
|
|
161
|
+
/** An open ask whose idle recipient the loop wakes again. */
|
|
162
|
+
interface Rewake {
|
|
163
|
+
readonly messageID: string
|
|
164
|
+
readonly sessionID: string
|
|
165
|
+
/** Re-wakes sent so far. */
|
|
166
|
+
readonly reminders: number
|
|
167
|
+
readonly nextAt: number
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** The wait after `reminders` re-wakes (the last delay repeats). */
|
|
171
|
+
const backoff = (reminders: number) => REWAKE_BACKOFF_MS[Math.min(reminders, REWAKE_BACKOFF_MS.length - 1)]!
|
|
172
|
+
|
|
173
|
+
/** A message waiting for its (offline) recipient session. */
|
|
174
|
+
interface Pending {
|
|
175
|
+
readonly messageID: string
|
|
176
|
+
readonly sessionID: string
|
|
177
|
+
readonly wake: boolean
|
|
178
|
+
readonly urgent: boolean
|
|
179
|
+
readonly at: number
|
|
180
|
+
readonly expiresAt: number
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** How a party appears in deliver meta: `@name`, `ses_…`, `@me`, `app:<tokenID>`. */
|
|
184
|
+
function wireAddress(party: Party): string {
|
|
185
|
+
switch (party.kind) {
|
|
186
|
+
case "session":
|
|
187
|
+
return party.name ? `@${party.name}` : party.sessionID
|
|
188
|
+
case "user":
|
|
189
|
+
return "@me"
|
|
190
|
+
case "app":
|
|
191
|
+
return `app:${party.tokenID}`
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export class Delivery {
|
|
196
|
+
readonly messages: Messages
|
|
197
|
+
readonly hops: HopTracker
|
|
198
|
+
readonly limiter: MessageLimiter
|
|
199
|
+
private readonly deps: DeliveryDeps
|
|
200
|
+
private readonly now: Clock
|
|
201
|
+
private readonly timers: Timers
|
|
202
|
+
private readonly newID: () => string
|
|
203
|
+
/** Per user: the flush in progress (flushes run one at a time). */
|
|
204
|
+
private readonly flushing = new Map<string, Promise<void>>()
|
|
205
|
+
/** Per user and session: the primer in flight (one at a time); true once it was admitted. */
|
|
206
|
+
private readonly priming = new Map<string, Promise<boolean>>()
|
|
207
|
+
/** Users seen since start (sent a message, or one of their spokes reported sessions): the loop looks at these. */
|
|
208
|
+
private readonly users = new Set<string>()
|
|
209
|
+
private timer: unknown
|
|
210
|
+
private running = false
|
|
211
|
+
private readonly owner: string
|
|
212
|
+
|
|
213
|
+
constructor(deps: DeliveryDeps) {
|
|
214
|
+
this.deps = deps
|
|
215
|
+
const now = (this.now = deps.now ?? Date.now)
|
|
216
|
+
this.timers = deps.timers ?? defaultTimers
|
|
217
|
+
this.newID = deps.newID ?? newMessageID
|
|
218
|
+
this.owner = deps.owner ?? `delivery-${process.pid}-${randomBytes(4).toString("hex")}`
|
|
219
|
+
this.messages = new Messages({ state: deps.state, now, newID: this.newID })
|
|
220
|
+
this.hops = new HopTracker({ state: deps.state, now })
|
|
221
|
+
this.limiter = new MessageLimiter({ counters: deps.counters, now, ...(deps.limits ? { limits: deps.limits } : {}) })
|
|
222
|
+
// An explicit (re)name primes again, so the session learns its new address.
|
|
223
|
+
deps.directory.onNamed?.((userID, sessionID) => void this.prime(userID, sessionID, { again: true }))
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Primes the session once (see the header; `again`: an explicit rename primes once more): no-op when it is primed, unknown, or no deliver-capable spoke of
|
|
228
|
+
* the user reports it. Resolves true when a primer was admitted now. Never throws.
|
|
229
|
+
*/
|
|
230
|
+
prime(userID: string, sessionID: string, options: { again?: boolean } = {}): Promise<boolean> {
|
|
231
|
+
const key = `${userID}\u0000${sessionID}`
|
|
232
|
+
const running = this.priming.get(key)
|
|
233
|
+
if (running) return running
|
|
234
|
+
const run = this.primeNow(userID, sessionID, options.again === true)
|
|
235
|
+
.catch((cause) => {
|
|
236
|
+
console.error(`[hub] primer failed: ${cause instanceof Error ? cause.message : String(cause)}`)
|
|
237
|
+
return false
|
|
238
|
+
})
|
|
239
|
+
.finally(() => this.priming.delete(key))
|
|
240
|
+
this.priming.set(key, run)
|
|
241
|
+
return run
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Stores a msg or ask from the actor to the session `addr` names and delivers it. A role goes to one holder:
|
|
246
|
+
* an idle one, else the most recently active, else (all offline) the one seen last; never the sending session
|
|
247
|
+
* itself (only itself: invalid). Throws invalid, not_found,
|
|
248
|
+
* ambiguous, unsupported {feature: "deliver"}, rate_limited or forbidden (muted) before anything is stored.
|
|
249
|
+
*/
|
|
250
|
+
async send(userID: string, actor: AgentActor, addr: string, kind: "msg" | "ask", text: string, options: SendOptions = {}): Promise<Sent> {
|
|
251
|
+
if (isUserAddress(addr)) return this.sendToUser(userID, actor, addr, kind, text, options)
|
|
252
|
+
const from = this.party(userID, actor)
|
|
253
|
+
const resolved = this.others(actor, this.deps.directory.resolve(userID, addr))
|
|
254
|
+
return this.sendTo(userID, from, addr, resolved, this.pick(resolved), kind, text, options)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Like send, to every session the address names (each role holder gets its own message). */
|
|
258
|
+
async sendAll(userID: string, actor: AgentActor, addr: string, kind: "msg" | "ask", text: string, options: SendOptions = {}): Promise<Sent[]> {
|
|
259
|
+
if (isUserAddress(addr)) return [await this.sendToUser(userID, actor, addr, kind, text, options)]
|
|
260
|
+
const from = this.party(userID, actor)
|
|
261
|
+
const resolved = this.others(actor, this.deps.directory.resolve(userID, addr))
|
|
262
|
+
const sent: Sent[] = []
|
|
263
|
+
for (const entry of resolved.sessions) sent.push(await this.sendTo(userID, from, addr, resolved, entry, kind, text, options))
|
|
264
|
+
return sent
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Replies to a message, from its recipient to its real sender (never via a role), and delivers the reply. */
|
|
268
|
+
reply(userID: string, actor: AgentActor, replyTo: string, text: string, options: SendOptions = {}): Promise<Replied> {
|
|
269
|
+
return this.answer(userID, actor, replyTo, options, (input) => this.messages.reply(userID, { ...input, text }))
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Declines an ask, with a reason, and delivers the decline to the asker. */
|
|
273
|
+
decline(userID: string, actor: AgentActor, replyTo: string, why: string, options: SendOptions = {}): Promise<Replied> {
|
|
274
|
+
return this.answer(userID, actor, replyTo, options, (input) => this.messages.decline(userID, { ...input, why }))
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** A human turn in the session (console prompt or utterance, or typed in the harness): its hop chain starts over. */
|
|
278
|
+
humanTurn(userID: string, sessionID: string): Promise<void> {
|
|
279
|
+
return this.hops.humanTurn(userID, sessionID)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Delivers the user's pending messages whose sessions a deliver-capable spoke reports now, oldest first. */
|
|
283
|
+
flush(userID: string): Promise<void> {
|
|
284
|
+
const previous = this.flushing.get(userID) ?? Promise.resolve()
|
|
285
|
+
const run = previous.then(() => this.flushNow(userID))
|
|
286
|
+
const tracked = run.finally(() => {
|
|
287
|
+
if (this.flushing.get(userID) === tracked) this.flushing.delete(userID)
|
|
288
|
+
})
|
|
289
|
+
this.flushing.set(userID, tracked)
|
|
290
|
+
return run
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Registry.onSessions: a spoke reported its sessions (connected, reconnected, or its periodic snapshot). */
|
|
294
|
+
async onSessions(connID: string): Promise<void> {
|
|
295
|
+
const userID = this.deps.registry.connTenant(connID)
|
|
296
|
+
if (userID === undefined) return
|
|
297
|
+
this.users.add(userID)
|
|
298
|
+
await this.flush(userID)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Starts the re-wake loop (every REWAKE_TICK_MS). */
|
|
302
|
+
start(): void {
|
|
303
|
+
if (this.running) return
|
|
304
|
+
this.running = true
|
|
305
|
+
this.schedule()
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
stop(): void {
|
|
309
|
+
this.running = false
|
|
310
|
+
if (this.timer !== undefined) this.timers.clear(this.timer)
|
|
311
|
+
this.timer = undefined
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** One pass of the re-wake loop over every known user (the loop calls it; tests may too). */
|
|
315
|
+
async tick(): Promise<void> {
|
|
316
|
+
let users: string[]
|
|
317
|
+
try {
|
|
318
|
+
users = this.deps.users ? [...(await this.deps.users())] : [...this.users]
|
|
319
|
+
} catch (cause) {
|
|
320
|
+
console.error(`[hub] re-wake failed: ${cause instanceof Error ? cause.message : String(cause)}`)
|
|
321
|
+
return
|
|
322
|
+
}
|
|
323
|
+
for (const userID of users) {
|
|
324
|
+
try {
|
|
325
|
+
await this.rewake(userID)
|
|
326
|
+
} catch (cause) {
|
|
327
|
+
console.error(`[hub] re-wake failed: ${cause instanceof Error ? cause.message : String(cause)}`)
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ---- internals ----------------------------------------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
private async primeNow(userID: string, sessionID: string, again: boolean): Promise<boolean> {
|
|
335
|
+
const { directory } = this.deps
|
|
336
|
+
if (!directory.primed || !directory.markPrimed || (!again && directory.primed(userID, sessionID))) return false
|
|
337
|
+
if (!this.targets(userID, sessionID).length) return false
|
|
338
|
+
const entry = directory.resolve(userID, sessionID).sessions[0]
|
|
339
|
+
if (!entry) return false
|
|
340
|
+
const roles = entry.roles.map((r) => (r.project === undefined ? r.role : `${r.role}.${r.project}`))
|
|
341
|
+
const req: DeliverRequest = {
|
|
342
|
+
op: "deliver",
|
|
343
|
+
sessionID,
|
|
344
|
+
messageID: this.newID(),
|
|
345
|
+
text: renderPrimer({ ...(entry.name ? { name: entry.name } : {}), sessionID, roles }),
|
|
346
|
+
wake: false,
|
|
347
|
+
meta: { kind: "primer", from: "hub", to: entry.name ? `@${entry.name}` : sessionID, delivery: "steer" },
|
|
348
|
+
}
|
|
349
|
+
if (!(await this.request(userID, sessionID, req))) return false
|
|
350
|
+
await directory.markPrimed(userID, sessionID)
|
|
351
|
+
return true
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** A msg or ask to the user (@me): stored only; a msg alerts the user. */
|
|
355
|
+
private async sendToUser(userID: string, actor: AgentActor, addr: string, kind: "msg" | "ask", text: string, options: SendOptions): Promise<Sent> {
|
|
356
|
+
if (addr !== USER_ADDRESS) throw new AgentError("invalid", "@me takes no project scope")
|
|
357
|
+
if (actor.kind === "user") throw new AgentError("invalid", "You are @me: the user cannot message themselves")
|
|
358
|
+
const from = this.party(userID, actor)
|
|
359
|
+
const recipient: Party = { kind: "user" }
|
|
360
|
+
await this.limiter.admit(userID, from, recipient)
|
|
361
|
+
const hop = await this.hops.next(userID, from)
|
|
362
|
+
const message = await this.messages.send(userID, {
|
|
363
|
+
from,
|
|
364
|
+
to: USER_ADDRESS,
|
|
365
|
+
recipient,
|
|
366
|
+
kind,
|
|
367
|
+
text,
|
|
368
|
+
hop,
|
|
369
|
+
...(options.expiresInMs === undefined ? {} : { expiresInMs: options.expiresInMs }),
|
|
370
|
+
})
|
|
371
|
+
this.users.add(userID)
|
|
372
|
+
if (kind === "msg") {
|
|
373
|
+
this.deps.alert?.(userID, { kind: "user", sessionID: from.kind === "session" ? from.sessionID : "", messageID: message.id, text: `${partyLabel(from)} sent you a message` })
|
|
374
|
+
}
|
|
375
|
+
return { message }
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
private async sendTo(userID: string, from: Party, addr: string, resolved: Resolved, entry: DirectoryEntry, kind: "msg" | "ask", text: string, options: SendOptions): Promise<Sent> {
|
|
379
|
+
const sessionID = entry.sessionID
|
|
380
|
+
const recipient: Party = { kind: "session", sessionID, ...(entry.name ? { name: entry.name } : {}) }
|
|
381
|
+
this.gate(userID, sessionID)
|
|
382
|
+
await this.limiter.admit(userID, from, recipient)
|
|
383
|
+
const hop = await this.hops.next(userID, from)
|
|
384
|
+
const generation = resolved.address.kind === "role" ? roleGeneration(entry, resolved.address) : undefined
|
|
385
|
+
const message = await this.messages.send(userID, {
|
|
386
|
+
from,
|
|
387
|
+
to: addr,
|
|
388
|
+
recipient,
|
|
389
|
+
kind,
|
|
390
|
+
text,
|
|
391
|
+
hop,
|
|
392
|
+
...(generation === undefined ? {} : { generation }),
|
|
393
|
+
...(options.expiresInMs === undefined ? {} : { expiresInMs: options.expiresInMs }),
|
|
394
|
+
})
|
|
395
|
+
await this.hops.received(userID, recipient, hop)
|
|
396
|
+
this.users.add(userID)
|
|
397
|
+
const wanted = options.wake !== false && entry.parentID === undefined
|
|
398
|
+
const wake = await this.wakeFor(userID, message, wanted)
|
|
399
|
+
if (kind === "ask" && wanted && !overHopCap(hop)) {
|
|
400
|
+
const rewake: Rewake = { messageID: message.id, sessionID, reminders: 0, nextAt: message.at + backoff(0) }
|
|
401
|
+
await this.deps.state.put(userID, REWAKE, message.id, rewake)
|
|
402
|
+
}
|
|
403
|
+
return this.deliver(userID, message, sessionID, wake, options.urgent === true)
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
private schedule(): void {
|
|
407
|
+
this.timer = this.timers.set(() => {
|
|
408
|
+
this.timer = undefined
|
|
409
|
+
void this.tick().finally(() => {
|
|
410
|
+
if (this.running && this.timer === undefined) this.schedule()
|
|
411
|
+
})
|
|
412
|
+
}, this.deps.tickMs ?? REWAKE_TICK_MS)
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** Re-wakes the user's idle recipients of due open asks; after the last re-wake, tells the user. */
|
|
416
|
+
private async rewake(userID: string): Promise<void> {
|
|
417
|
+
const { registry } = this.deps
|
|
418
|
+
for (const { key, value } of await this.deps.state.list<Rewake>(userID, REWAKE)) {
|
|
419
|
+
const t = this.now()
|
|
420
|
+
if (value.nextAt > t) continue
|
|
421
|
+
const ask = await this.messages.get(userID, value.messageID)
|
|
422
|
+
if (!ask || ask.state !== "open") {
|
|
423
|
+
await this.deps.state.delete(userID, REWAKE, key)
|
|
424
|
+
continue
|
|
425
|
+
}
|
|
426
|
+
const postpone = () => this.deps.state.put(userID, REWAKE, key, { ...value, nextAt: t + backoff(value.reminders) })
|
|
427
|
+
const reachable = ask.delivery !== undefined && ask.delivery !== "pending" && this.targets(userID, value.sessionID).length > 0
|
|
428
|
+
if (!reachable || !registry.idle(value.sessionID)) {
|
|
429
|
+
await postpone()
|
|
430
|
+
continue
|
|
431
|
+
}
|
|
432
|
+
if (value.reminders >= REWAKE_BACKOFF_MS.length) {
|
|
433
|
+
await this.deps.state.delete(userID, REWAKE, key)
|
|
434
|
+
this.deps.alert?.(userID, {
|
|
435
|
+
kind: "unanswered",
|
|
436
|
+
sessionID: value.sessionID,
|
|
437
|
+
messageID: ask.id,
|
|
438
|
+
text: `${partyLabel(ask.recipient)} has not answered ${partyLabel(ask.from)}'s ask after ${value.reminders} reminders`,
|
|
439
|
+
})
|
|
440
|
+
continue
|
|
441
|
+
}
|
|
442
|
+
if (!(await this.limiter.wake(userID, ask.recipient)) || !(await this.remind(userID, ask, value.sessionID))) {
|
|
443
|
+
await postpone()
|
|
444
|
+
continue
|
|
445
|
+
}
|
|
446
|
+
const reminders = value.reminders + 1
|
|
447
|
+
await this.deps.state.put(userID, REWAKE, key, { ...value, reminders, nextAt: t + backoff(reminders) })
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Sends a short wake-up about an open ask (its own id: the ask itself was already admitted). */
|
|
452
|
+
private async remind(userID: string, ask: Message, sessionID: string): Promise<boolean> {
|
|
453
|
+
const id = this.newID()
|
|
454
|
+
const hint = `↩ hub reply ${ask.id} "…" · hub decline ${ask.id} --why "…"`
|
|
455
|
+
const req: DeliverRequest = {
|
|
456
|
+
op: "deliver",
|
|
457
|
+
sessionID,
|
|
458
|
+
messageID: id,
|
|
459
|
+
text: `[hub] reminder · ask ${ask.id} from ${partyLabel(ask.from)} is still open · ${id}\n${hint}`,
|
|
460
|
+
wake: true,
|
|
461
|
+
meta: { kind: "ask", from: wireAddress(ask.from), to: ask.to, delivery: "steer" },
|
|
462
|
+
}
|
|
463
|
+
return (await this.request(userID, sessionID, req)) !== undefined
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** The resolved sessions without the sending session itself; invalid when only it is left (no self-messages). */
|
|
467
|
+
private others(actor: AgentActor, resolved: Resolved): Resolved {
|
|
468
|
+
if (actor.kind !== "session") return resolved
|
|
469
|
+
const sessions = resolved.sessions.filter((s) => s.sessionID !== actor.sessionID)
|
|
470
|
+
if (!sessions.length) throw new AgentError("invalid", "A session cannot message itself")
|
|
471
|
+
return { ...resolved, sessions }
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** One session of a resolved address (see send). */
|
|
475
|
+
private pick(resolved: Resolved): DirectoryEntry {
|
|
476
|
+
const { sessions } = resolved
|
|
477
|
+
if (sessions.length === 1 || resolved.address.kind !== "role") return sessions[0]!
|
|
478
|
+
const { registry } = this.deps
|
|
479
|
+
const online = sessions.filter((s) => s.online)
|
|
480
|
+
if (!online.length) return latest(sessions, (s) => s.lastSeen ?? 0)
|
|
481
|
+
const idle = online.filter((s) => registry.idle(s.sessionID))
|
|
482
|
+
return latest(idle.length ? idle : online, (s) => registry.recency(s.sessionID))
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
private async answer(
|
|
486
|
+
userID: string,
|
|
487
|
+
actor: AgentActor,
|
|
488
|
+
replyTo: string,
|
|
489
|
+
options: SendOptions,
|
|
490
|
+
store: (input: { from: Party; replyTo: string; hop: number; generation?: number }) => Promise<{ message: Message; original: Message }>,
|
|
491
|
+
): Promise<Replied> {
|
|
492
|
+
const from = this.party(userID, actor)
|
|
493
|
+
const original = await this.messages.get(userID, replyTo)
|
|
494
|
+
const to = original && partyKey(original.recipient) === partyKey(from) ? original.from : undefined
|
|
495
|
+
if (to?.kind === "session") this.gate(userID, to.sessionID)
|
|
496
|
+
if (to) await this.limiter.admit(userID, from, to)
|
|
497
|
+
const hop = await this.hops.next(userID, from)
|
|
498
|
+
const generation = original && actor.kind === "session" ? this.holderGeneration(userID, actor.sessionID, original) : undefined
|
|
499
|
+
const answered = await store({ from, replyTo, hop, ...(generation === undefined ? {} : { generation }) })
|
|
500
|
+
const { message } = answered
|
|
501
|
+
await this.hops.received(userID, message.recipient, hop)
|
|
502
|
+
this.users.add(userID)
|
|
503
|
+
if (message.recipient.kind !== "session") return answered
|
|
504
|
+
const sessionID = message.recipient.sessionID
|
|
505
|
+
const wake = await this.wakeFor(userID, message, options.wake !== false && !this.isSubagent(userID, sessionID))
|
|
506
|
+
return { ...(await this.deliver(userID, message, sessionID, wake, true)), original: answered.original }
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/** Whether the message may start a turn: wanted, under the hop cap (alerting once at the cap), wake budget left. */
|
|
510
|
+
private async wakeFor(userID: string, message: Message, wanted: boolean): Promise<boolean> {
|
|
511
|
+
if (!wanted || message.recipient.kind !== "session") return false
|
|
512
|
+
if (overHopCap(message.hop)) {
|
|
513
|
+
if (message.hop === HOP_CAP + 1) {
|
|
514
|
+
this.deps.alert?.(userID, {
|
|
515
|
+
kind: "hopCap",
|
|
516
|
+
sessionID: message.recipient.sessionID,
|
|
517
|
+
messageID: message.id,
|
|
518
|
+
text: `Sessions messaged each other ${HOP_CAP} times without you; further messages no longer wake them`,
|
|
519
|
+
})
|
|
520
|
+
}
|
|
521
|
+
return false
|
|
522
|
+
}
|
|
523
|
+
return this.limiter.wake(userID, message.recipient)
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** The replier's generation of the role the original reached (undefined: not a role, or not held now). */
|
|
527
|
+
private holderGeneration(userID: string, sessionID: string, original: Message): number | undefined {
|
|
528
|
+
const address = parseAddress(original.to)
|
|
529
|
+
if (original.generation === undefined || address?.kind !== "role") return undefined
|
|
530
|
+
try {
|
|
531
|
+
const entry = this.deps.directory.resolve(userID, sessionID).sessions[0]
|
|
532
|
+
return entry && roleGeneration(entry, address)
|
|
533
|
+
} catch {
|
|
534
|
+
return undefined
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
private isSubagent(userID: string, sessionID: string): boolean {
|
|
539
|
+
try {
|
|
540
|
+
return this.deps.directory.resolve(userID, sessionID).sessions[0]?.parentID !== undefined
|
|
541
|
+
} catch {
|
|
542
|
+
return false
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** Refuses (unsupported) a session that only spokes without deliver report; an offline one is fine (pending). */
|
|
547
|
+
private gate(userID: string, sessionID: string): void {
|
|
548
|
+
const targets = this.deps.registry.sessionTargets(sessionID).filter((connID) => this.deps.registry.connTenant(connID) === userID)
|
|
549
|
+
if (targets.length && !targets.some((connID) => this.deps.registry.supports(connID, "deliver"))) {
|
|
550
|
+
throw new AgentError("unsupported", "The recipient's spoke cannot receive messages: update the hub plugin", { extra: { feature: "deliver" } })
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** The user's deliver-capable connections for the session, the owner first. */
|
|
555
|
+
private targets(userID: string, sessionID: string): string[] {
|
|
556
|
+
const { registry } = this.deps
|
|
557
|
+
return registry.sessionTargets(sessionID).filter((connID) => registry.connTenant(connID) === userID && registry.supports(connID, "deliver"))
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** Delivers now, or queues the message as pending when no spoke takes it. */
|
|
561
|
+
private async deliver(userID: string, message: Message, sessionID: string, wake: boolean, urgent: boolean): Promise<Sent> {
|
|
562
|
+
const delivered = await this.attempt(userID, message, sessionID, wake, urgent)
|
|
563
|
+
if (delivered) return delivered
|
|
564
|
+
const at = this.now()
|
|
565
|
+
const pending: Pending = { messageID: message.id, sessionID, wake, urgent, at, expiresAt: at + PENDING_MS }
|
|
566
|
+
await this.deps.state.put(userID, PENDING, message.id, pending)
|
|
567
|
+
return { message: (await this.messages.setDelivery(userID, message.id, "pending")) ?? message, delivery: "pending" }
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** Tries each deliver-capable connection in turn (same message id: the harness dedupes it). */
|
|
571
|
+
private async attempt(userID: string, message: Message, sessionID: string, wake: boolean, urgent: boolean): Promise<Sent | undefined> {
|
|
572
|
+
// A primer just admitted waits (steer, no resume) for the next turn. A queued msg waking an idle session would
|
|
573
|
+
// come after that turn's reply with a model call of its own (quirk hzmcqwpj): steer it too, one call for both.
|
|
574
|
+
const primed = await this.prime(userID, sessionID)
|
|
575
|
+
const steer = urgent || (primed && wake && this.deps.registry.idle(sessionID))
|
|
576
|
+
const result = await this.request(userID, sessionID, this.deliverRequest(message, sessionID, wake, steer))
|
|
577
|
+
if (!result) return undefined
|
|
578
|
+
const updated = (await this.messages.setDelivery(userID, message.id, result.delivered)) ?? message
|
|
579
|
+
return { message: updated, delivery: result.delivered }
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/** Sends the request to each of the user's deliver-capable connections for the session until one takes it. */
|
|
583
|
+
private async request(userID: string, sessionID: string, req: DeliverRequest): Promise<DeliverResult | undefined> {
|
|
584
|
+
for (const connID of this.targets(userID, sessionID)) {
|
|
585
|
+
let result: unknown
|
|
586
|
+
try {
|
|
587
|
+
result = await this.deps.send(connID, req)
|
|
588
|
+
} catch {
|
|
589
|
+
continue
|
|
590
|
+
}
|
|
591
|
+
if (isDeliverResult(result)) return result
|
|
592
|
+
}
|
|
593
|
+
return undefined
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
private async flushNow(userID: string): Promise<void> {
|
|
597
|
+
const entries = (await this.deps.state.list<Pending>(userID, PENDING)).sort((a, b) => a.value.at - b.value.at || (a.key < b.key ? -1 : 1))
|
|
598
|
+
/** Cluster mode: the sessions whose lease this flush holds (true) or another process holds (false). */
|
|
599
|
+
const leases = new Map<string, boolean>()
|
|
600
|
+
try {
|
|
601
|
+
for (const { key, value, version } of entries) {
|
|
602
|
+
const message = value.expiresAt > this.now() ? await this.messages.get(userID, value.messageID) : undefined
|
|
603
|
+
// Gone, or an ask that closed (expired) while it waited: nothing to deliver.
|
|
604
|
+
if (!message || (message.kind === "ask" && message.state !== "open")) {
|
|
605
|
+
await this.deps.state.delete(userID, PENDING, key, { ifVersion: version })
|
|
606
|
+
continue
|
|
607
|
+
}
|
|
608
|
+
if (!this.targets(userID, value.sessionID).length) continue
|
|
609
|
+
if (!(await this.lease(userID, value.sessionID, leases))) continue
|
|
610
|
+
// Another process may have delivered it before this one took the lease.
|
|
611
|
+
if (this.deps.locks && !(await this.deps.state.get(userID, PENDING, key))) continue
|
|
612
|
+
if (await this.attempt(userID, message, value.sessionID, value.wake, value.urgent)) await this.deps.state.delete(userID, PENDING, key)
|
|
613
|
+
}
|
|
614
|
+
} finally {
|
|
615
|
+
const { locks } = this.deps
|
|
616
|
+
for (const [sessionID, held] of leases) {
|
|
617
|
+
if (held) await locks!.release(flushKey(userID, sessionID), this.owner).catch(() => false)
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/** Takes the (user, session) flush lease once per flush (always true without locks); false: another process flushes it. */
|
|
623
|
+
private async lease(userID: string, sessionID: string, leases: Map<string, boolean>): Promise<boolean> {
|
|
624
|
+
const { locks } = this.deps
|
|
625
|
+
if (!locks) return true
|
|
626
|
+
const known = leases.get(sessionID)
|
|
627
|
+
if (known !== undefined) return known
|
|
628
|
+
let held = false
|
|
629
|
+
try {
|
|
630
|
+
held = (await locks.acquire(flushKey(userID, sessionID), this.owner, FLUSH_LEASE_MS)) !== undefined
|
|
631
|
+
} catch (cause) {
|
|
632
|
+
console.error(`[hub] message flush lease failed: ${cause instanceof Error ? cause.message : String(cause)}`)
|
|
633
|
+
}
|
|
634
|
+
leases.set(sessionID, held)
|
|
635
|
+
return held
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/** `steer`: urgent, or the first msg after a primer to an idle session (see attempt). */
|
|
639
|
+
private deliverRequest(message: Message, sessionID: string, wake: boolean, steer: boolean): DeliverRequest {
|
|
640
|
+
const delivery: DeliveryMode = message.kind === "msg" && !steer ? "queue" : "steer"
|
|
641
|
+
return {
|
|
642
|
+
op: "deliver",
|
|
643
|
+
sessionID,
|
|
644
|
+
messageID: message.id,
|
|
645
|
+
text: renderEnvelope({ id: message.id, kind: message.kind, from: message.from, to: message.to, at: message.at, text: message.text, ...(message.replyTo ? { replyTo: message.replyTo } : {}), ...(message.expiresAt === undefined ? {} : { expiresAt: message.expiresAt }) }),
|
|
646
|
+
wake,
|
|
647
|
+
meta: { kind: message.kind as WireKind, from: wireAddress(message.from), to: message.to, ...(message.replyTo ? { replyTo: message.replyTo } : {}), delivery },
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/** The sender, from the authenticated actor only. */
|
|
652
|
+
private party(userID: string, actor: AgentActor): Party {
|
|
653
|
+
if (actor.kind !== "session") return partyFromActor(actor)
|
|
654
|
+
return partyFromActor(actor, this.deps.describe?.(userID, actor.sessionID) ?? this.directoryInfo(userID, actor.sessionID))
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
private directoryInfo(userID: string, sessionID: string): PartyInfo {
|
|
658
|
+
try {
|
|
659
|
+
const name = this.deps.directory.resolve(userID, sessionID).sessions[0]?.name
|
|
660
|
+
return name ? { name } : {}
|
|
661
|
+
} catch {
|
|
662
|
+
return {}
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/** The Locks key of a (user, session) pending flush. */
|
|
668
|
+
const flushKey = (userID: string, sessionID: string) => `deliver:${userID}:${sessionID}`
|
|
669
|
+
|
|
670
|
+
/** The session's holder generation of the role (and project scope) an address names; undefined: not held. */
|
|
671
|
+
function roleGeneration(entry: DirectoryEntry, address: { readonly role: string; readonly project?: string }): number | undefined {
|
|
672
|
+
return entry.roles.find((r) => r.role === address.role && (address.project === undefined || r.project === undefined || r.project === address.project))?.generation
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/** The entry with the highest score (the first on ties). */
|
|
676
|
+
function latest<T>(list: readonly T[], score: (item: T) => number): T {
|
|
677
|
+
return list.reduce((best, item) => (score(item) > score(best) ? item : best))
|
|
678
|
+
}
|