@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
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
//
|
|
2
|
-
// hub send requests into its own network (SSRF).
|
|
3
|
-
//
|
|
4
|
-
// unspecified or multicast
|
|
1
|
+
// URLs users give the hub (push endpoints from browsers, webhook URLs from apps) are URLs the hub POSTs to: without
|
|
2
|
+
// checks a subscription could make the hub send requests into its own network (SSRF). publicURLProblem: https on a
|
|
3
|
+
// public hostname whose every resolved address is public (not loopback, private, link-local, CGNAT, ULA,
|
|
4
|
+
// unspecified or multicast); an admin opt-in (allowPrivate, webhooks only) admits http and private targets.
|
|
5
|
+
// Push checks endpoints at subscribe time (real push services are public names on public addresses); webhooks
|
|
6
|
+
// send through publicFetch, which re-checks (and re-resolves) right before every request, never follows a
|
|
7
|
+
// redirect, times out and reads a bounded body.
|
|
5
8
|
import { lookup } from "node:dns/promises"
|
|
6
9
|
import { isIPv4, isIPv6 } from "node:net"
|
|
7
10
|
|
|
@@ -10,7 +13,7 @@ export type ResolveHost = (host: string) => Promise<readonly string[]>
|
|
|
10
13
|
/** Every address the system resolver returns for a host. */
|
|
11
14
|
export const resolveHost: ResolveHost = async (host) => (await lookup(host, { all: true, verbatim: true })).map((a) => a.address)
|
|
12
15
|
|
|
13
|
-
/** IPv4 [network, prefix length] blocks that never
|
|
16
|
+
/** IPv4 [network, prefix length] blocks that are never a public service. */
|
|
14
17
|
const V4_BLOCKED: ReadonlyArray<readonly [string, number]> = [
|
|
15
18
|
["0.0.0.0", 8], // "this network", unspecified
|
|
16
19
|
["10.0.0.0", 8], // private
|
|
@@ -47,7 +50,7 @@ function v6Groups(ip: string): number[] {
|
|
|
47
50
|
return [...left, ...fill, ...right].map((g) => parseInt(g, 16))
|
|
48
51
|
}
|
|
49
52
|
|
|
50
|
-
/** True for addresses a
|
|
53
|
+
/** True for addresses a public URL must not resolve to. Anything that is not an IP counts as private. */
|
|
51
54
|
export function isPrivateAddress(address: string): boolean {
|
|
52
55
|
const ip = address.trim().replace(/^\[(.*)\]$/, "$1")
|
|
53
56
|
if (isIPv4(ip)) return privateV4(ip)
|
|
@@ -70,28 +73,125 @@ export function isPrivateAddress(address: string): boolean {
|
|
|
70
73
|
|
|
71
74
|
const LOCAL_SUFFIXES = [".localhost", ".local", ".internal", ".localdomain", ".home.arpa", ".lan"]
|
|
72
75
|
|
|
73
|
-
|
|
74
|
-
|
|
76
|
+
export interface PublicURLOptions {
|
|
77
|
+
readonly resolve: ResolveHost
|
|
78
|
+
/** How problems name the URL (default "URL"). */
|
|
79
|
+
readonly label?: string
|
|
80
|
+
/**
|
|
81
|
+
* The admin opt-in for private targets (hub.json agents.webhooks.allowPrivate): http, loopback, private
|
|
82
|
+
* addresses and local names pass without a lookup. Other schemes and credentials still fail.
|
|
83
|
+
*/
|
|
84
|
+
readonly allowPrivate?: boolean
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Why the hub must not send to a URL (SSRF), or undefined when it may. */
|
|
88
|
+
export async function publicURLProblem(raw: string, options: PublicURLOptions): Promise<string | undefined> {
|
|
89
|
+
const label = options.label ?? "URL"
|
|
75
90
|
let url: URL
|
|
76
91
|
try {
|
|
77
|
-
url = new URL(
|
|
92
|
+
url = new URL(raw)
|
|
78
93
|
} catch {
|
|
79
|
-
return
|
|
94
|
+
return `${label} is not a URL`
|
|
80
95
|
}
|
|
81
|
-
if (
|
|
82
|
-
|
|
96
|
+
if (options.allowPrivate) {
|
|
97
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") return `${label} must be http or https`
|
|
98
|
+
} else if (url.protocol !== "https:") return `${label} must be https`
|
|
99
|
+
if (url.username || url.password) return `${label} must not carry credentials`
|
|
100
|
+
if (options.allowPrivate) return undefined
|
|
83
101
|
const host = url.hostname.toLowerCase().replace(/^\[(.*)\]$/, "$1").replace(/\.$/, "")
|
|
84
|
-
if (isIPv4(host) || isIPv6(host)) return isPrivateAddress(host) ?
|
|
102
|
+
if (isIPv4(host) || isIPv6(host)) return isPrivateAddress(host) ? `${label} is a private or local address` : undefined
|
|
85
103
|
if (host === "localhost" || !host.includes(".") || LOCAL_SUFFIXES.some((suffix) => host.endsWith(suffix))) {
|
|
86
|
-
return
|
|
104
|
+
return `${label} is a local hostname`
|
|
87
105
|
}
|
|
88
106
|
let addresses: readonly string[]
|
|
89
107
|
try {
|
|
90
|
-
addresses = await resolve(host)
|
|
108
|
+
addresses = await options.resolve(host)
|
|
91
109
|
} catch {
|
|
92
|
-
return
|
|
110
|
+
return `${label} host ${host} does not resolve`
|
|
93
111
|
}
|
|
94
|
-
if (addresses.length === 0) return
|
|
95
|
-
if (addresses.some(isPrivateAddress)) return
|
|
112
|
+
if (addresses.length === 0) return `${label} host ${host} does not resolve`
|
|
113
|
+
if (addresses.some(isPrivateAddress)) return `${label} resolves to a private or local address`
|
|
96
114
|
return undefined
|
|
97
115
|
}
|
|
116
|
+
|
|
117
|
+
/** Why an endpoint is not acceptable for Web Push, or undefined when it is. */
|
|
118
|
+
export async function pushEndpointProblem(endpoint: string, resolve: ResolveHost): Promise<string | undefined> {
|
|
119
|
+
return publicURLProblem(endpoint, { resolve, label: "Push endpoint" })
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The URL failed publicURLProblem at send time; nothing was sent. */
|
|
123
|
+
export class PublicURLError extends Error {
|
|
124
|
+
constructor(readonly problem: string) {
|
|
125
|
+
super(problem)
|
|
126
|
+
this.name = "PublicURLError"
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface PublicFetchOptions extends PublicURLOptions {
|
|
131
|
+
/** For the whole exchange, body included. */
|
|
132
|
+
readonly timeoutMs: number
|
|
133
|
+
/** Bytes of the response body read at most; the rest is discarded. */
|
|
134
|
+
readonly maxBytes: number
|
|
135
|
+
readonly fetch?: typeof globalThis.fetch
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface PublicFetchResult {
|
|
139
|
+
readonly status: number
|
|
140
|
+
/** The first maxBytes of the body (UTF-8, lossy). */
|
|
141
|
+
readonly body: string
|
|
142
|
+
readonly truncated: boolean
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* One request to a user-supplied URL: the URL is checked (and its host re-resolved) right before sending, so a
|
|
147
|
+
* name that changed to a private address since registration is refused; redirects are never followed (a 3xx is
|
|
148
|
+
* returned as is); the timeout covers the body; at most maxBytes of the body are read.
|
|
149
|
+
* A rebinding between this lookup and fetch's own remains possible; private targets need the admin opt-in.
|
|
150
|
+
*/
|
|
151
|
+
export async function publicFetch(
|
|
152
|
+
url: string,
|
|
153
|
+
init: { readonly method: string; readonly headers?: Record<string, string>; readonly body: string },
|
|
154
|
+
options: PublicFetchOptions,
|
|
155
|
+
): Promise<PublicFetchResult> {
|
|
156
|
+
const problem = await publicURLProblem(url, options)
|
|
157
|
+
if (problem) throw new PublicURLError(problem)
|
|
158
|
+
const controller = new AbortController()
|
|
159
|
+
let timedOut = false
|
|
160
|
+
const timer = setTimeout(() => {
|
|
161
|
+
timedOut = true
|
|
162
|
+
controller.abort()
|
|
163
|
+
}, options.timeoutMs)
|
|
164
|
+
try {
|
|
165
|
+
const response = await (options.fetch ?? globalThis.fetch)(url, { ...init, redirect: "manual", signal: controller.signal })
|
|
166
|
+
const { body, truncated } = await readCapped(response, options.maxBytes)
|
|
167
|
+
return { status: response.status, body, truncated }
|
|
168
|
+
} catch (cause) {
|
|
169
|
+
if (timedOut) throw new Error(`timed out after ${options.timeoutMs} ms`)
|
|
170
|
+
throw cause
|
|
171
|
+
} finally {
|
|
172
|
+
clearTimeout(timer)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function readCapped(response: Response, maxBytes: number): Promise<{ body: string; truncated: boolean }> {
|
|
177
|
+
if (!response.body) return { body: "", truncated: false }
|
|
178
|
+
const reader = response.body.getReader()
|
|
179
|
+
const chunks: Uint8Array[] = []
|
|
180
|
+
let size = 0
|
|
181
|
+
let truncated = false
|
|
182
|
+
while (true) {
|
|
183
|
+
const { done, value } = await reader.read()
|
|
184
|
+
if (done) break
|
|
185
|
+
const room = maxBytes - size
|
|
186
|
+
if (value.byteLength > room) {
|
|
187
|
+
chunks.push(value.subarray(0, room))
|
|
188
|
+
size = maxBytes
|
|
189
|
+
truncated = true
|
|
190
|
+
await reader.cancel().catch(() => {})
|
|
191
|
+
break
|
|
192
|
+
}
|
|
193
|
+
chunks.push(value)
|
|
194
|
+
size += value.byteLength
|
|
195
|
+
}
|
|
196
|
+
return { body: Buffer.concat(chunks).toString("utf8"), truncated }
|
|
197
|
+
}
|
package/src/central/push.ts
CHANGED
|
@@ -1,22 +1,52 @@
|
|
|
1
|
-
// Web push (VAPID) for paired devices: hand-offs
|
|
2
|
-
//
|
|
3
|
-
// sessionID, title, body, requestID?}: no tokens, no
|
|
1
|
+
// Web push (VAPID) for paired devices, one toggle per notification kind (docs/notifications.md): hand-offs, questions,
|
|
2
|
+
// permissions, all done, failed / stopped / retrying runs, every turn, subagents, scripts, long tools, spoken replies
|
|
3
|
+
// and the activity digest. Payloads carry only {kind, sessionID, title, body, requestID?, test?}: no tokens, no
|
|
4
|
+
// transcript beyond one spoken text or a short reason, shortened. Each device picks the kinds it wants, may set quiet
|
|
5
|
+
// hours, and users mute sessions.
|
|
4
6
|
import webpush from "web-push"
|
|
5
|
-
import {
|
|
7
|
+
import { MemoryKv } from "./backend/memory-kv"
|
|
8
|
+
import type { Kv } from "./backend/types"
|
|
9
|
+
import { type PushPrefs, type PushTarget, type QuietHours, type Store, type VapidKeys } from "./store"
|
|
6
10
|
|
|
7
11
|
export { DEFAULT_PUSH_PREFS, type PushPrefs } from "./store"
|
|
8
12
|
|
|
9
|
-
/**
|
|
10
|
-
export type PushKind = "handoff" | "blocked" | "finished" | "say"
|
|
11
|
-
/** What a blocked or finished notification is about. */
|
|
12
|
-
export type PushDetail = "permission" | "form" | "succeeded" | "failed"
|
|
13
|
-
/** The precise notification kind; one per device toggle. */
|
|
13
|
+
/** The notification kind; one per device toggle. */
|
|
14
14
|
export type NotificationKind = keyof PushPrefs
|
|
15
15
|
|
|
16
|
+
/** Short label of each kind, shown in the title after the session name (and in Settings). */
|
|
17
|
+
export const NOTIFICATION_LABELS: Readonly<Record<NotificationKind, string>> = Object.freeze({
|
|
18
|
+
handoff: "Hand-off",
|
|
19
|
+
question: "Question",
|
|
20
|
+
permission: "Needs permission",
|
|
21
|
+
done: "All done",
|
|
22
|
+
failed: "Failed",
|
|
23
|
+
turn: "Turn finished",
|
|
24
|
+
stopped: "Stopped",
|
|
25
|
+
retry: "Retrying",
|
|
26
|
+
subagentDone: "Subagent finished",
|
|
27
|
+
subagentFailed: "Subagent failed",
|
|
28
|
+
jobDone: "Script finished",
|
|
29
|
+
jobFailed: "Script failed",
|
|
30
|
+
longTool: "Long tool finished",
|
|
31
|
+
say: "Spoken reply",
|
|
32
|
+
activity: "Activity",
|
|
33
|
+
decision: "Decision",
|
|
34
|
+
message: "Message",
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
/** True for a known notification kind (API input). */
|
|
38
|
+
export const isNotificationKind = (value: unknown): value is NotificationKind => typeof value === "string" && Object.hasOwn(NOTIFICATION_LABELS, value)
|
|
39
|
+
|
|
16
40
|
/** Extra facts for one notification. */
|
|
17
41
|
export interface PushExtra {
|
|
18
42
|
/** permission: the request ID, so the notification can answer it. */
|
|
19
43
|
readonly requestID?: string
|
|
44
|
+
/** What the notification is about, for the title: a subagent's title, a job's outcome ("exited 1"), a duration. */
|
|
45
|
+
readonly subject?: string
|
|
46
|
+
/** Identity of what ended (subagent session, job, tool call): the cooldown is per session, kind and key. Not sent. */
|
|
47
|
+
readonly key?: string
|
|
48
|
+
/** decision: the Decision, so a tap opens its card. */
|
|
49
|
+
readonly decisionID?: string
|
|
20
50
|
}
|
|
21
51
|
|
|
22
52
|
export interface PushPayload {
|
|
@@ -25,18 +55,9 @@ export interface PushPayload {
|
|
|
25
55
|
readonly title: string
|
|
26
56
|
readonly body: string
|
|
27
57
|
readonly requestID?: string
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
export function pushKindFor(kind: PushKind, detail?: PushDetail): NotificationKind {
|
|
32
|
-
switch (kind) {
|
|
33
|
-
case "blocked":
|
|
34
|
-
return detail === "form" ? "question" : "permission"
|
|
35
|
-
case "finished":
|
|
36
|
-
return detail === "failed" ? "failed" : "finished"
|
|
37
|
-
default:
|
|
38
|
-
return kind
|
|
39
|
-
}
|
|
58
|
+
readonly decisionID?: string
|
|
59
|
+
/** A test notification shaped like `kind`. */
|
|
60
|
+
readonly test?: true
|
|
40
61
|
}
|
|
41
62
|
|
|
42
63
|
const minutes = (clock: string): number => Number(clock.slice(0, 2)) * 60 + Number(clock.slice(3, 5))
|
|
@@ -80,6 +101,7 @@ export const URGENT_TTL_SECONDS = 3600
|
|
|
80
101
|
/** One test notification per device in this window. */
|
|
81
102
|
export const TEST_COOLDOWN_MS = 10_000
|
|
82
103
|
const SEND_TIMEOUT_MS = 10_000
|
|
104
|
+
const COOL_KEY = (key: string) => `push:cool:${key}`
|
|
83
105
|
const MAX_NAME = 60
|
|
84
106
|
|
|
85
107
|
const webPushSender: PushSender = (target, payload, options) =>
|
|
@@ -91,6 +113,51 @@ export interface PushOptions {
|
|
|
91
113
|
readonly subject: string
|
|
92
114
|
readonly sender?: PushSender
|
|
93
115
|
readonly now?: () => number
|
|
116
|
+
/** Timers for delayed activity digests (tests). */
|
|
117
|
+
readonly timers?: { set(fn: () => void, ms: number): unknown; clear(handle: unknown): void }
|
|
118
|
+
/** Cooldowns (default: this process, on `now`; Valkey in a cluster, so pods share them). */
|
|
119
|
+
readonly kv?: Kv
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** One step of interest in a session tree, for activity updates. */
|
|
123
|
+
export interface ActivityStep {
|
|
124
|
+
/** tool: a short tool call (only counted); anything else is a line of the digest and sends it. */
|
|
125
|
+
readonly kind: "tool" | "long" | "subagent" | "job" | "idle"
|
|
126
|
+
readonly text: string
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** An activity step of a root session, as the registry reports it. */
|
|
130
|
+
export interface ActivityAlert {
|
|
131
|
+
readonly sessionID: string
|
|
132
|
+
readonly title: string
|
|
133
|
+
readonly step?: ActivityStep
|
|
134
|
+
readonly owner?: string
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Lines of a digest kept (older ones become "+n more"). */
|
|
138
|
+
const DIGEST_LINES = 5
|
|
139
|
+
|
|
140
|
+
interface Digest {
|
|
141
|
+
title: string
|
|
142
|
+
tools: number
|
|
143
|
+
lines: string[]
|
|
144
|
+
last?: number
|
|
145
|
+
timer?: unknown
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const realTimers = {
|
|
149
|
+
set: (fn: () => void, ms: number) => {
|
|
150
|
+
const handle = setTimeout(fn, ms)
|
|
151
|
+
;(handle as { unref?: () => void }).unref?.()
|
|
152
|
+
return handle
|
|
153
|
+
},
|
|
154
|
+
clear: (handle: unknown) => clearTimeout(handle as ReturnType<typeof setTimeout>),
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** "3 tools · explore finished · bun run build exited 0". */
|
|
158
|
+
export function digestBody(tools: number, lines: readonly string[]): string {
|
|
159
|
+
const shown = lines.length > DIGEST_LINES ? [`+${lines.length - DIGEST_LINES + 1} more`, ...lines.slice(-(DIGEST_LINES - 1))] : [...lines]
|
|
160
|
+
return [...(tools ? [`${tools} tool${tools === 1 ? "" : "s"}`] : []), ...shown].join(" · ")
|
|
94
161
|
}
|
|
95
162
|
|
|
96
163
|
function clip(text: string, max: number): string {
|
|
@@ -100,48 +167,83 @@ function clip(text: string, max: number): string {
|
|
|
100
167
|
|
|
101
168
|
const shorten = (title: string): string => clip(title, MAX_NAME) || "Session"
|
|
102
169
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
170
|
+
const MAX_SUBJECT = 40
|
|
171
|
+
|
|
172
|
+
/** The title after the session name: the kind's label, with its subject where it has one. */
|
|
173
|
+
function kindTitle(kind: NotificationKind, subject: string | undefined): string {
|
|
174
|
+
const about = subject ? clip(subject, MAX_SUBJECT) : ""
|
|
175
|
+
switch (kind) {
|
|
176
|
+
case "subagentDone":
|
|
177
|
+
return about ? `Subagent ${about} finished` : NOTIFICATION_LABELS.subagentDone
|
|
178
|
+
case "subagentFailed":
|
|
179
|
+
return about ? `Subagent ${about} failed` : NOTIFICATION_LABELS.subagentFailed
|
|
180
|
+
case "jobDone":
|
|
181
|
+
case "jobFailed":
|
|
182
|
+
return about ? `Script ${about}` : NOTIFICATION_LABELS[kind]
|
|
183
|
+
case "longTool":
|
|
184
|
+
return about ? `${NOTIFICATION_LABELS.longTool} (${about})` : NOTIFICATION_LABELS.longTool
|
|
185
|
+
default:
|
|
186
|
+
return NOTIFICATION_LABELS[kind]
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const OPEN = "Tap to open the session."
|
|
191
|
+
|
|
192
|
+
export function pushPayload(kind: NotificationKind, sessionID: string, sessionTitle: string, text?: string, extra: PushExtra = {}): PushPayload {
|
|
111
193
|
const name = shorten(sessionTitle)
|
|
112
|
-
const
|
|
113
|
-
|
|
194
|
+
const detail = clip(text ?? "", SAY_MAX_CHARS)
|
|
195
|
+
const title = `${name} · ${kindTitle(kind, extra.subject)}`
|
|
196
|
+
switch (kind) {
|
|
114
197
|
case "say":
|
|
115
|
-
|
|
198
|
+
// A spoken reply reads as the session talking.
|
|
199
|
+
return { kind, sessionID, title: name, body: detail || "New reply. Tap to listen." }
|
|
116
200
|
case "handoff":
|
|
117
|
-
return { kind
|
|
201
|
+
return { kind, sessionID, title, body: "Sent from the terminal. Tap to talk." }
|
|
118
202
|
case "question":
|
|
119
|
-
return { kind
|
|
203
|
+
return { kind, sessionID, title, body: "Tap to see it." }
|
|
204
|
+
// An agent wrote the question or message: never in the payload (the console shows it after sign-in).
|
|
205
|
+
case "decision":
|
|
206
|
+
return { kind, sessionID, title, body: "A decision waits for you. Tap to answer.", ...(extra.decisionID ? { decisionID: extra.decisionID } : {}) }
|
|
207
|
+
case "message":
|
|
208
|
+
return { kind, sessionID, title, body: "Tap to see it." }
|
|
120
209
|
case "permission":
|
|
121
210
|
// Never the request's summary (it may be a command line); the service worker fetches it from the hub.
|
|
122
|
-
return {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
}
|
|
129
|
-
case "failed":
|
|
130
|
-
return { kind: precise, sessionID, title: `${name} failed`, body: "Tap to open the session." }
|
|
131
|
-
case "finished":
|
|
132
|
-
return { kind: precise, sessionID, title: `${name} finished`, body: "Tap to open the session." }
|
|
211
|
+
return { kind, sessionID, title, body: "Tap to review and answer.", ...(extra.requestID ? { requestID: extra.requestID } : {}) }
|
|
212
|
+
case "done":
|
|
213
|
+
return { kind, sessionID, title, body: detail || "Nothing is running or waiting. Tap to open." }
|
|
214
|
+
case "activity":
|
|
215
|
+
return { kind, sessionID, title, body: detail || "New activity." }
|
|
216
|
+
default:
|
|
217
|
+
return { kind, sessionID, title, body: detail || OPEN }
|
|
133
218
|
}
|
|
134
219
|
}
|
|
135
220
|
|
|
221
|
+
/** What a test notification of each kind is about. */
|
|
222
|
+
const TEST_SUBJECT: Partial<Record<NotificationKind, string>> = { subagentDone: "explore", subagentFailed: "explore", jobDone: "exited 0", jobFailed: "exited 1", longTool: "45s" }
|
|
223
|
+
|
|
136
224
|
/** web-push options per notification kind: urgent kinds wake a dozing phone and wait longer for it. */
|
|
137
225
|
export function sendOptionsFor(kind: NotificationKind | "test"): { TTL: number; urgency: "high" | "normal" } {
|
|
138
226
|
switch (kind) {
|
|
139
227
|
case "question":
|
|
140
228
|
case "permission":
|
|
141
229
|
case "failed":
|
|
230
|
+
case "decision":
|
|
231
|
+
case "message":
|
|
142
232
|
return { TTL: URGENT_TTL_SECONDS, urgency: "high" }
|
|
143
|
-
case "
|
|
233
|
+
case "done":
|
|
234
|
+
case "stopped":
|
|
235
|
+
case "subagentFailed":
|
|
236
|
+
case "jobFailed":
|
|
237
|
+
// Worth seeing later, not worth waking a dozing phone.
|
|
144
238
|
return { TTL: URGENT_TTL_SECONDS, urgency: "normal" }
|
|
239
|
+
case "turn":
|
|
240
|
+
case "retry":
|
|
241
|
+
case "subagentDone":
|
|
242
|
+
case "jobDone":
|
|
243
|
+
case "longTool":
|
|
244
|
+
case "activity":
|
|
245
|
+
// Progress: stale soon, replaced by the next one.
|
|
246
|
+
return { TTL: PUSH_TTL_SECONDS, urgency: "normal" }
|
|
145
247
|
default:
|
|
146
248
|
return { TTL: PUSH_TTL_SECONDS, urgency: "high" }
|
|
147
249
|
}
|
|
@@ -170,19 +272,96 @@ export class Push {
|
|
|
170
272
|
private readonly subject: string
|
|
171
273
|
private readonly sender: PushSender
|
|
172
274
|
private readonly now: () => number
|
|
173
|
-
/** `session kind` → when it was
|
|
174
|
-
private readonly
|
|
275
|
+
/** Cooldowns: `push:cool:<session kind>` / `push:cool:test <device>` → when it was sent, expiring with the cooldown. */
|
|
276
|
+
private readonly kv: Kv
|
|
277
|
+
/**
|
|
278
|
+
* `device session` → the activity digest being collected. Kept in this process: activity is reported by the
|
|
279
|
+
* pod holding the session's connection.
|
|
280
|
+
*/
|
|
281
|
+
private readonly digests = new Map<string, Digest>()
|
|
282
|
+
private readonly timers: NonNullable<PushOptions["timers"]>
|
|
175
283
|
|
|
176
284
|
constructor(options: PushOptions) {
|
|
177
285
|
this.store = options.store
|
|
178
286
|
this.subject = options.subject
|
|
179
287
|
this.sender = options.sender ?? webPushSender
|
|
180
288
|
this.now = options.now ?? Date.now
|
|
289
|
+
this.timers = options.timers ?? realTimers
|
|
290
|
+
this.kv = options.kv ?? new MemoryKv({ now: this.now })
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Activity updates: collects a session's steps per device that wants them (pref `activity`, not muted, not in
|
|
295
|
+
* quiet hours) and sends one collapsing notification: at once when the device got none for this session within
|
|
296
|
+
* its interval, else when the interval ends, with everything that happened meanwhile. Short tools only count.
|
|
297
|
+
*/
|
|
298
|
+
activity(alert: ActivityAlert): void {
|
|
299
|
+
const step = alert.step
|
|
300
|
+
if (!alert.owner || !step) return
|
|
301
|
+
const at = this.now()
|
|
302
|
+
for (const deviceID of this.activityDevices(alert.owner, alert.sessionID, at)) {
|
|
303
|
+
const key = `${deviceID}\n${alert.sessionID}`
|
|
304
|
+
let digest = this.digests.get(key)
|
|
305
|
+
if (!digest) this.digests.set(key, (digest = { title: alert.title, tools: 0, lines: [] }))
|
|
306
|
+
digest.title = alert.title
|
|
307
|
+
if (step.kind === "tool") {
|
|
308
|
+
digest.tools++
|
|
309
|
+
continue
|
|
310
|
+
}
|
|
311
|
+
if (digest.lines.at(-1) !== step.text) digest.lines.push(step.text)
|
|
312
|
+
if (digest.lines.length > 50) digest.lines.splice(0, digest.lines.length - 50)
|
|
313
|
+
if (digest.timer !== undefined) continue
|
|
314
|
+
const wait = digest.last === undefined ? 0 : digest.last + this.store.activityMinutes(deviceID) * 60_000 - at
|
|
315
|
+
if (wait <= 0) void this.flushDigest(deviceID, alert.sessionID, alert.owner)
|
|
316
|
+
else {
|
|
317
|
+
const owner = alert.owner
|
|
318
|
+
digest.timer = this.timers.set(() => void this.flushDigest(deviceID, alert.sessionID, owner), wait)
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
this.pruneDigests(at)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Devices of `owner` with a subscription that want activity updates for the session now. */
|
|
325
|
+
private activityDevices(owner: string, sessionID: string, at: number): string[] {
|
|
326
|
+
if (this.store.mutedSessions(owner).includes(sessionID)) return []
|
|
327
|
+
const mine = new Set(this.store.devices().filter((d) => d.ownerID === owner).map((d) => d.id))
|
|
328
|
+
const devices = new Set<string>()
|
|
329
|
+
for (const s of this.store.pushSubscriptions()) {
|
|
330
|
+
if (!mine.has(s.deviceID) || devices.has(s.deviceID) || !this.store.pushPrefs(s.deviceID).activity) continue
|
|
331
|
+
const quiet = this.store.quietHours(s.deviceID)
|
|
332
|
+
if (quiet && inQuietHours(quiet, at)) continue
|
|
333
|
+
devices.add(s.deviceID)
|
|
334
|
+
}
|
|
335
|
+
return [...devices]
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
private async flushDigest(deviceID: string, sessionID: string, owner: string): Promise<void> {
|
|
339
|
+
const key = `${deviceID}\n${sessionID}`
|
|
340
|
+
const digest = this.digests.get(key)
|
|
341
|
+
if (!digest) return
|
|
342
|
+
if (digest.timer !== undefined) this.timers.clear(digest.timer)
|
|
343
|
+
digest.timer = undefined
|
|
344
|
+
const body = digestBody(digest.tools, digest.lines)
|
|
345
|
+
digest.tools = 0
|
|
346
|
+
digest.lines = []
|
|
347
|
+
digest.last = this.now()
|
|
348
|
+
// Settings may have changed while it waited.
|
|
349
|
+
if (!body || !this.activityDevices(owner, sessionID, digest.last).includes(deviceID)) return
|
|
350
|
+
const targets = this.store.pushSubscriptions().filter((s) => s.deviceID === deviceID)
|
|
351
|
+
await this.send(targets, pushPayload("activity", sessionID, digest.title, body))
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Forgets digests idle for an hour (nothing pending, interval long over). */
|
|
355
|
+
private pruneDigests(at: number): void {
|
|
356
|
+
if (this.digests.size < 1000) return
|
|
357
|
+
for (const [key, digest] of this.digests) {
|
|
358
|
+
if (digest.timer === undefined && !digest.lines.length && (digest.last ?? 0) < at - 3_600_000) this.digests.delete(key)
|
|
359
|
+
}
|
|
181
360
|
}
|
|
182
361
|
|
|
183
362
|
/** The VAPID public key (applicationServerKey); generated and persisted on first use. */
|
|
184
|
-
publicKey(): string {
|
|
185
|
-
return this.keys().publicKey
|
|
363
|
+
async publicKey(): Promise<string> {
|
|
364
|
+
return (await this.keys()).publicKey
|
|
186
365
|
}
|
|
187
366
|
|
|
188
367
|
/**
|
|
@@ -191,17 +370,10 @@ export class Push {
|
|
|
191
370
|
* in its quiet hours (hand-offs, an explicit request from the terminal, go through both). Returns the
|
|
192
371
|
* number of successful deliveries.
|
|
193
372
|
*/
|
|
194
|
-
async notify(
|
|
195
|
-
kind
|
|
196
|
-
sessionID:
|
|
197
|
-
|
|
198
|
-
detail?: PushDetail,
|
|
199
|
-
body?: string,
|
|
200
|
-
ownerID?: string,
|
|
201
|
-
extra?: PushExtra,
|
|
202
|
-
): Promise<number> {
|
|
203
|
-
const precise = pushKindFor(kind, detail)
|
|
204
|
-
if (!this.cool(`${sessionID}\n${precise}`, precise === "say" ? SAY_COOLDOWN_MS : PUSH_COOLDOWN_MS)) return 0
|
|
373
|
+
async notify(kind: NotificationKind, sessionID: string, title: string, body?: string, ownerID?: string, extra?: PushExtra): Promise<number> {
|
|
374
|
+
const precise = kind
|
|
375
|
+
const key = `${sessionID}\n${precise}${extra?.key ? `\n${extra.key}` : ""}`
|
|
376
|
+
if (!(await this.cool(key, precise === "say" ? SAY_COOLDOWN_MS : PUSH_COOLDOWN_MS))) return 0
|
|
205
377
|
const owners = new Map(this.store.devices().map((d) => [d.id, d.ownerID]))
|
|
206
378
|
const at = this.now()
|
|
207
379
|
const muted = new Map<string, boolean>()
|
|
@@ -219,21 +391,26 @@ export class Push {
|
|
|
219
391
|
return !isMuted(owner) && !(quiet && inQuietHours(quiet, at))
|
|
220
392
|
})
|
|
221
393
|
if (targets.length === 0) return 0
|
|
222
|
-
const payload = pushPayload(kind, sessionID, title,
|
|
394
|
+
const payload = pushPayload(kind, sessionID, title, body, extra)
|
|
223
395
|
const { delivered } = await this.send(targets, payload)
|
|
224
396
|
return delivered
|
|
225
397
|
}
|
|
226
398
|
|
|
227
|
-
/**
|
|
228
|
-
|
|
399
|
+
/**
|
|
400
|
+
* Sends a test notification to `deviceID`'s own subscriptions, whatever its prefs; one per TEST_COOLDOWN_MS. With
|
|
401
|
+
* a kind, it looks like that kind (title, urgency) for a "Test session".
|
|
402
|
+
*/
|
|
403
|
+
async test(deviceID: string, kind?: NotificationKind): Promise<PushTestResult> {
|
|
229
404
|
const key = `test\n${deviceID}`
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
405
|
+
if (!(await this.cool(key, TEST_COOLDOWN_MS))) {
|
|
406
|
+
const at = Number(await this.kv.get(COOL_KEY(key)))
|
|
407
|
+
const left = Number.isFinite(at) ? TEST_COOLDOWN_MS - (this.now() - at) : TEST_COOLDOWN_MS
|
|
408
|
+
return { limited: true, retryAfter: Math.max(1, Math.ceil(left / 1000)) }
|
|
233
409
|
}
|
|
234
|
-
this.cool(key, TEST_COOLDOWN_MS)
|
|
235
410
|
const targets = this.store.pushSubscriptions().filter((s) => s.deviceID === deviceID)
|
|
236
|
-
const payload: PushPayload =
|
|
411
|
+
const payload: PushPayload = kind
|
|
412
|
+
? { ...pushPayload(kind, "", "Test session", `Test: this is how “${NOTIFICATION_LABELS[kind]}” looks on this device.`, { subject: TEST_SUBJECT[kind] }), test: true }
|
|
413
|
+
: { kind: "test", sessionID: "", title: "Hub", body: "Test notification: notifications work on this device." }
|
|
237
414
|
const { delivered, failed } = await this.send(targets, payload)
|
|
238
415
|
return { sent: delivered, failed }
|
|
239
416
|
}
|
|
@@ -242,7 +419,7 @@ export class Push {
|
|
|
242
419
|
targets: readonly { endpoint: string; keys: PushTarget["keys"] }[],
|
|
243
420
|
payload: PushPayload,
|
|
244
421
|
): Promise<{ delivered: number; failed: Array<{ host: string; status?: number }> }> {
|
|
245
|
-
const keys = this.keys()
|
|
422
|
+
const keys = await this.keys()
|
|
246
423
|
const options: PushSendOptions = {
|
|
247
424
|
...sendOptionsFor(payload.kind),
|
|
248
425
|
timeout: SEND_TIMEOUT_MS,
|
|
@@ -259,27 +436,24 @@ export class Push {
|
|
|
259
436
|
const status = statusOf(result.reason)
|
|
260
437
|
const endpoint = targets[index]!.endpoint
|
|
261
438
|
failed.push({ host: hostOf(endpoint), ...(status !== undefined ? { status } : {}) })
|
|
262
|
-
if (status === 404 || status === 410)
|
|
439
|
+
if (status === 404 || status === 410) {
|
|
440
|
+
void this.store.dropPushSubscription(endpoint).catch((cause: unknown) => console.error(`[hub] push subscription not dropped: ${(cause as Error)?.message ?? cause}`))
|
|
441
|
+
}
|
|
263
442
|
else console.error(`[hub] push to ${hostOf(endpoint)} failed: ${status ?? (result.reason as Error)?.message}`)
|
|
264
443
|
})
|
|
265
444
|
return { delivered, failed }
|
|
266
445
|
}
|
|
267
446
|
|
|
268
|
-
/** True (and records the send) when
|
|
269
|
-
private cool(key: string, cooldown: number): boolean {
|
|
270
|
-
|
|
271
|
-
for (const [k, sent] of this.lastSent) if (t - sent.at >= sent.cooldown) this.lastSent.delete(k)
|
|
272
|
-
if (this.lastSent.has(key)) return false
|
|
273
|
-
this.lastSent.set(key, { at: t, cooldown })
|
|
274
|
-
return true
|
|
447
|
+
/** True (and records the send, SET NX PX: one pod wins) when `key` is outside its cooldown. */
|
|
448
|
+
private cool(key: string, cooldown: number): Promise<boolean> {
|
|
449
|
+
return this.kv.set(COOL_KEY(key), String(this.now()), { px: cooldown, nx: true })
|
|
275
450
|
}
|
|
276
451
|
|
|
277
|
-
|
|
452
|
+
/** The stored keys; the first call generates a pair (when pods race, every pod gets the pair stored first). */
|
|
453
|
+
private async keys(): Promise<VapidKeys> {
|
|
278
454
|
const existing = this.store.vapid()
|
|
279
455
|
if (existing) return existing
|
|
280
|
-
|
|
281
|
-
this.store.setVapid(keys)
|
|
282
|
-
return keys
|
|
456
|
+
return this.store.setVapid(webpush.generateVAPIDKeys())
|
|
283
457
|
}
|
|
284
458
|
}
|
|
285
459
|
|