@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,385 @@
|
|
|
1
|
+
// Reading on /api/agent (design §4): the snapshot and the flat session list, built only from the hub's catalog
|
|
2
|
+
// (Registry) and directory, never from a spoke.
|
|
3
|
+
//
|
|
4
|
+
// Routes (user actors always; session tokens and app tokens holding sessions:read):
|
|
5
|
+
// GET /api/agent/status
|
|
6
|
+
// GET /api/agent/sessions?project=&state=&machine=&label=k=v
|
|
7
|
+
// GET /api/agent/sessions/:addr show: the catalog view plus the spoke's summary (when it serves one)
|
|
8
|
+
// GET /api/agent/sessions/:addr/log?n=20 the newest timeline rows (spoke feature "timeline")
|
|
9
|
+
// GET /api/agent/watch?blocked=1&session=<addr> SSE of compact events (watch.ts)
|
|
10
|
+
// show and log call spokes; a spoke without the feature answers unsupported {feature, harness}.
|
|
11
|
+
// A session a launch started carries that launch's labels (the latest launch's, when several did); others have none.
|
|
12
|
+
import path from "node:path"
|
|
13
|
+
import { featureForOp, type ServerRequest, type SpokeFeature } from "../../protocol"
|
|
14
|
+
import type { CatalogMachine, CatalogSession, LiveStatus, NowActivity, RegistryMessage, SessionView } from "../registry"
|
|
15
|
+
import { unsupportedMessage } from "../actions"
|
|
16
|
+
import { SpokeError } from "../spoke-ws"
|
|
17
|
+
import type { Store } from "../store"
|
|
18
|
+
import type { Directory, DirectoryEntry, RoleView } from "./directory"
|
|
19
|
+
import type { AgentActor } from "./actor"
|
|
20
|
+
import { AgentError } from "./errors"
|
|
21
|
+
import { AGENT_PREFIX, type AgentRoute } from "./routes"
|
|
22
|
+
import { watchStream } from "./watch"
|
|
23
|
+
import type { EventsRouteDeps } from "./events-routes"
|
|
24
|
+
|
|
25
|
+
/** Labels per session ID. */
|
|
26
|
+
export type SessionLabels = ReadonlyMap<string, Readonly<Record<string, string>>>
|
|
27
|
+
|
|
28
|
+
/** What the read routes need from the Registry. */
|
|
29
|
+
export interface ReadRegistry {
|
|
30
|
+
catalog(): CatalogMachine[]
|
|
31
|
+
sessionTenant(sessionID: string): string | undefined
|
|
32
|
+
session(sessionID: string): SessionView | undefined
|
|
33
|
+
owner(sessionID: string): string | undefined
|
|
34
|
+
supports(connID: string, feature: SpokeFeature): boolean
|
|
35
|
+
subscribe(listener: (message: RegistryMessage, tenant?: string) => void): () => void
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Hub → spoke requests (SpokeHub). */
|
|
39
|
+
export interface SpokeRequester {
|
|
40
|
+
request(connID: string, req: ServerRequest): Promise<unknown>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ReadDeps {
|
|
44
|
+
readonly store: Pick<Store, "machines">
|
|
45
|
+
readonly directory: Directory
|
|
46
|
+
readonly registry: ReadRegistry
|
|
47
|
+
readonly spokes?: SpokeRequester
|
|
48
|
+
/** The labels of the user's launched sessions (none without it). */
|
|
49
|
+
readonly labels?: (userID: string) => Promise<SessionLabels>
|
|
50
|
+
/** The journal GET /api/agent/watch streams (no watch route without it). */
|
|
51
|
+
readonly events?: EventsRouteDeps
|
|
52
|
+
/** Watch stream heartbeat (default WATCH_HEARTBEAT_MS). */
|
|
53
|
+
readonly heartbeatMs?: number
|
|
54
|
+
/** Aborted when the hub drains: watch streams end with a `retry:` hint. */
|
|
55
|
+
readonly draining?: AbortSignal
|
|
56
|
+
readonly now?: () => number
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** One session in agent answers (status, sessions). */
|
|
60
|
+
export interface AgentSessionView {
|
|
61
|
+
readonly id: string
|
|
62
|
+
readonly name?: string
|
|
63
|
+
readonly roles: string[]
|
|
64
|
+
readonly title: string
|
|
65
|
+
readonly state: string
|
|
66
|
+
readonly harness: string
|
|
67
|
+
readonly parentID?: string
|
|
68
|
+
readonly busySince?: number
|
|
69
|
+
readonly now?: string
|
|
70
|
+
readonly blockers: { kind: string; id: string; summary: string; sessionID?: string }[]
|
|
71
|
+
readonly children: number
|
|
72
|
+
readonly labels: Record<string, string>
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface AttentionItem {
|
|
76
|
+
readonly kind: string
|
|
77
|
+
readonly sessionID: string
|
|
78
|
+
readonly name?: string
|
|
79
|
+
readonly id?: string
|
|
80
|
+
readonly summary: string
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A session the user owns, where it is reported. */
|
|
84
|
+
interface Row {
|
|
85
|
+
readonly machineID: string
|
|
86
|
+
readonly harness: string
|
|
87
|
+
readonly session: CatalogSession
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const roleLabel = (r: RoleView) => (r.project !== undefined ? `${r.role}.${r.project}` : r.role)
|
|
91
|
+
|
|
92
|
+
/** "bash: bun test", from the session's latest running activity item. */
|
|
93
|
+
const nowText = (now: NowActivity) => [now.tool ?? (now.kind === "thinking" ? "thinking" : undefined), now.summary].filter(Boolean).join(": ") || now.kind
|
|
94
|
+
|
|
95
|
+
/** The user's sessions in the catalog, once each (the first process reporting one wins). */
|
|
96
|
+
function rows(registry: ReadRegistry, userID: string): Row[] {
|
|
97
|
+
const out = new Map<string, Row>()
|
|
98
|
+
for (const machine of registry.catalog()) {
|
|
99
|
+
for (const process of machine.processes) {
|
|
100
|
+
for (const session of process.sessions) {
|
|
101
|
+
if (out.has(session.id) || registry.sessionTenant(session.id) !== userID) continue
|
|
102
|
+
out.set(session.id, { machineID: machine.id, harness: process.harness, session })
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return [...out.values()]
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const NO_LABELS: SessionLabels = new Map()
|
|
110
|
+
|
|
111
|
+
function view({ session: s, harness }: Row, entry: DirectoryEntry | undefined, labels: SessionLabels): AgentSessionView {
|
|
112
|
+
const own = s.status.find((e) => e.id === s.id) as LiveStatus | undefined
|
|
113
|
+
return {
|
|
114
|
+
id: s.id,
|
|
115
|
+
...(entry?.name ? { name: `@${entry.name}` } : {}),
|
|
116
|
+
roles: (entry?.roles ?? []).map(roleLabel),
|
|
117
|
+
title: own?.title ?? s.title,
|
|
118
|
+
state: s.state,
|
|
119
|
+
harness,
|
|
120
|
+
...(s.parentID ? { parentID: s.parentID } : {}),
|
|
121
|
+
...(own?.since !== undefined ? { busySince: own.since } : {}),
|
|
122
|
+
...(s.now ? { now: nowText(s.now) } : {}),
|
|
123
|
+
blockers: s.blockers.map((b) => ({ kind: b.kind, id: b.id, summary: b.summary, ...(b.sessionID !== s.id ? { sessionID: b.sessionID } : {}) })),
|
|
124
|
+
children: s.status.filter((e) => e.parentID === s.id).length,
|
|
125
|
+
labels: { ...labels.get(s.id) },
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const projectOf = (s: CatalogSession) => {
|
|
130
|
+
const root = s.project || s.directory
|
|
131
|
+
return { root, name: s.projectName || path.basename(root) }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function directoryOf(directory: Directory, userID: string): Map<string, DirectoryEntry> {
|
|
135
|
+
return new Map(directory.who(userID).map((e) => [e.sessionID, e]))
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Blockers and errors across the user's sessions, oldest first (by the session's last update). */
|
|
139
|
+
function attention(list: readonly Row[], entries: Map<string, DirectoryEntry>): AttentionItem[] {
|
|
140
|
+
const out: AttentionItem[] = []
|
|
141
|
+
const seen = new Set<string>()
|
|
142
|
+
const named = (sessionID: string) => {
|
|
143
|
+
const name = entries.get(sessionID)?.name
|
|
144
|
+
return name ? { name: `@${name}` } : {}
|
|
145
|
+
}
|
|
146
|
+
for (const { session: s } of [...list].sort((a, b) => a.session.updated - b.session.updated)) {
|
|
147
|
+
for (const b of s.blockers) {
|
|
148
|
+
if (seen.has(b.id)) continue
|
|
149
|
+
seen.add(b.id)
|
|
150
|
+
out.push({ kind: b.kind, sessionID: b.sessionID, ...named(b.sessionID), id: b.id, summary: b.summary })
|
|
151
|
+
}
|
|
152
|
+
if (s.state === "error" && !seen.has(`error:${s.id}`)) {
|
|
153
|
+
seen.add(`error:${s.id}`)
|
|
154
|
+
const own = s.status.find((e) => e.id === s.id)
|
|
155
|
+
out.push({ kind: "error", sessionID: s.id, ...named(s.id), summary: own?.detail ?? "error" })
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return out
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function you(actor: AgentActor, entries: Map<string, DirectoryEntry>) {
|
|
162
|
+
if (actor.kind === "session") {
|
|
163
|
+
const name = entries.get(actor.sessionID)?.name
|
|
164
|
+
return { kind: "session", sessionID: actor.sessionID, ...(name ? { name: `@${name}` } : {}) }
|
|
165
|
+
}
|
|
166
|
+
if (actor.kind === "app") return { kind: "app", tokenID: actor.tokenID, ...(actor.label ? { label: actor.label } : {}) }
|
|
167
|
+
return { kind: "user" }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** The status snapshot of one user (design §4): machines → projects → sessions, and what needs attention. */
|
|
171
|
+
export function snapshot(deps: ReadDeps, userID: string, actor: AgentActor, labels: SessionLabels = NO_LABELS) {
|
|
172
|
+
const list = rows(deps.registry, userID)
|
|
173
|
+
const entries = directoryOf(deps.directory, userID)
|
|
174
|
+
const online = new Map(deps.registry.catalog().map((m) => [m.id, m]))
|
|
175
|
+
const machines = deps.store
|
|
176
|
+
.machines()
|
|
177
|
+
.filter((m) => !m.revoked && m.ownerID === userID)
|
|
178
|
+
.map((m) => {
|
|
179
|
+
const live = online.get(m.id)
|
|
180
|
+
const projects = new Map<string, { root: string; name: string; sessions: AgentSessionView[] }>()
|
|
181
|
+
for (const row of list) {
|
|
182
|
+
if (row.machineID !== m.id) continue
|
|
183
|
+
const project = projectOf(row.session)
|
|
184
|
+
const group = projects.get(project.root) ?? { ...project, sessions: [] }
|
|
185
|
+
projects.set(project.root, group)
|
|
186
|
+
group.sessions.push(view(row, entries.get(row.session.id), labels))
|
|
187
|
+
}
|
|
188
|
+
return {
|
|
189
|
+
id: m.id,
|
|
190
|
+
name: m.name,
|
|
191
|
+
online: live !== undefined,
|
|
192
|
+
harnesses: [...new Set((live?.processes ?? []).map((p) => p.harness))],
|
|
193
|
+
projects: [...projects.values()],
|
|
194
|
+
}
|
|
195
|
+
})
|
|
196
|
+
return { at: (deps.now ?? Date.now)(), you: you(actor, entries), machines, attention: attention(list, entries) }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** The session an address names for the user, if the user's spokes report it now (else offline). */
|
|
200
|
+
function located(deps: ReadDeps, userID: string, addr: string) {
|
|
201
|
+
const entry = deps.directory.resolveOne(userID, addr)
|
|
202
|
+
const live = deps.registry.sessionTenant(entry.sessionID) === userID ? deps.registry.session(entry.sessionID) : undefined
|
|
203
|
+
const list = rows(deps.registry, userID)
|
|
204
|
+
const row = list.find((r) => r.session.id === entry.sessionID) ?? (live && list.find((r) => r.session.id === live.status[0]?.id))
|
|
205
|
+
return { entry, live, row }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** The connection to ask for `op` about a session: unsupported when it lacks the op's feature, offline when none. */
|
|
209
|
+
function spokeFor(deps: ReadDeps, sessionID: string, op: ServerRequest["op"], harness: string): string {
|
|
210
|
+
const connID = deps.registry.owner(sessionID)
|
|
211
|
+
if (!connID || !deps.spokes) throw new AgentError("offline", "No spoke reports this session now")
|
|
212
|
+
const feature = featureForOp(op)
|
|
213
|
+
if (feature && !deps.registry.supports(connID, feature)) {
|
|
214
|
+
throw new AgentError("unsupported", unsupportedMessage({ feature, harness }), { extra: { feature, harness } })
|
|
215
|
+
}
|
|
216
|
+
return connID
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function ask(deps: ReadDeps, connID: string, req: ServerRequest): Promise<unknown> {
|
|
220
|
+
try {
|
|
221
|
+
return await deps.spokes!.request(connID, req)
|
|
222
|
+
} catch (cause) {
|
|
223
|
+
if (cause instanceof SpokeError && cause.message.includes("disconnected")) throw new AgentError("offline", "The session's spoke disconnected")
|
|
224
|
+
throw cause
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** GET /sessions/:addr: the session's catalog view with full blockers, and its summary from the spoke. */
|
|
229
|
+
async function show(deps: ReadDeps, userID: string, addr: string) {
|
|
230
|
+
const { entry, live, row } = located(deps, userID, addr)
|
|
231
|
+
const labels = { ...(await labelsOf(deps, userID)).get(entry.sessionID) }
|
|
232
|
+
const name = entry.name ? { name: `@${entry.name}` } : {}
|
|
233
|
+
const roles = entry.roles.map(roleLabel)
|
|
234
|
+
if (!live) {
|
|
235
|
+
return {
|
|
236
|
+
id: entry.sessionID,
|
|
237
|
+
...name,
|
|
238
|
+
roles,
|
|
239
|
+
...(entry.title ? { title: entry.title } : {}),
|
|
240
|
+
state: "offline",
|
|
241
|
+
online: false,
|
|
242
|
+
...(entry.parentID ? { parentID: entry.parentID } : {}),
|
|
243
|
+
...(entry.project ? { project: entry.project } : {}),
|
|
244
|
+
...(entry.machineID ? { machineID: entry.machineID } : {}),
|
|
245
|
+
...(entry.lastSeen !== undefined ? { lastSeen: entry.lastSeen } : {}),
|
|
246
|
+
blockers: [],
|
|
247
|
+
labels,
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
const harness = row?.harness ?? "unknown"
|
|
251
|
+
const own = live.status.find((e) => e.id === live.id) as LiveStatus | undefined
|
|
252
|
+
const base = {
|
|
253
|
+
id: live.id,
|
|
254
|
+
...name,
|
|
255
|
+
roles,
|
|
256
|
+
title: live.title,
|
|
257
|
+
state: live.state,
|
|
258
|
+
harness,
|
|
259
|
+
online: true,
|
|
260
|
+
...(row ? { machineID: row.machineID } : {}),
|
|
261
|
+
project: live.projectName || path.basename(live.project || live.directory),
|
|
262
|
+
directory: live.directory,
|
|
263
|
+
...(live.parentID ? { parentID: live.parentID } : {}),
|
|
264
|
+
...(own?.since !== undefined ? { busySince: own.since } : {}),
|
|
265
|
+
...(live.now ? { now: nowText(live.now), activity: live.now } : {}),
|
|
266
|
+
blockers: live.blockers.map(({ sessionTitle: _, ...b }) => b),
|
|
267
|
+
children: live.status.filter((e) => e.parentID === live.id).length,
|
|
268
|
+
labels,
|
|
269
|
+
}
|
|
270
|
+
try {
|
|
271
|
+
const connID = spokeFor(deps, live.id, "summary", harness)
|
|
272
|
+
return { ...base, summary: await ask(deps, connID, { op: "summary", sessionID: live.id }) }
|
|
273
|
+
} catch (cause) {
|
|
274
|
+
if (cause instanceof AgentError && cause.code === "unsupported") return { ...base, unsupported: [cause.options.extra] }
|
|
275
|
+
if (cause instanceof AgentError && cause.code === "offline") return base
|
|
276
|
+
console.error("[hub] agent show: summary failed:", cause)
|
|
277
|
+
return { ...base, failed: [{ feature: "summary" }] }
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** GET /sessions/:addr/log: the newest `n` timeline rows, newest first. */
|
|
282
|
+
async function log(deps: ReadDeps, userID: string, addr: string, raw: string | null) {
|
|
283
|
+
const n = raw === null ? 20 : Number(raw)
|
|
284
|
+
if (!Number.isInteger(n) || n < 1 || n > 200) throw new AgentError("invalid", "n must be 1–200")
|
|
285
|
+
const { entry, live, row } = located(deps, userID, addr)
|
|
286
|
+
if (!live) throw new AgentError("offline", "No spoke reports this session now")
|
|
287
|
+
const connID = spokeFor(deps, entry.sessionID, "timeline", row?.harness ?? "unknown")
|
|
288
|
+
const page = (await ask(deps, connID, { op: "timeline", sessionID: entry.sessionID, limit: n, order: "desc" })) as { items?: unknown[]; older?: string }
|
|
289
|
+
return { sessionID: entry.sessionID, rows: page.items ?? [], ...(page.older ? { older: page.older } : {}) }
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const labelsOf = (deps: ReadDeps, userID: string): Promise<SessionLabels> => deps.labels?.(userID) ?? Promise.resolve(NO_LABELS)
|
|
293
|
+
|
|
294
|
+
const STATES = new Set(["idle", "working", "blocked", "error"])
|
|
295
|
+
|
|
296
|
+
/** The /sessions filters: project (name or root), state (comma list), machine (ID or name), label k=v. */
|
|
297
|
+
function sessionFilter(deps: ReadDeps, userID: string, params: URLSearchParams): (row: Row, view: AgentSessionView) => boolean {
|
|
298
|
+
const project = params.get("project") || undefined
|
|
299
|
+
const states = params.get("state")?.split(",").filter(Boolean)
|
|
300
|
+
if (states?.some((state) => !STATES.has(state))) throw new AgentError("invalid", "state must be idle, working, blocked or error (comma-separated)")
|
|
301
|
+
const machine = params.get("machine") || undefined
|
|
302
|
+
const machineIDs = machine && new Set(deps.store.machines().filter((m) => m.ownerID === userID && (m.id === machine || m.name === machine)).map((m) => m.id))
|
|
303
|
+
const labels = params.getAll("label").map((label) => {
|
|
304
|
+
const at = label.indexOf("=")
|
|
305
|
+
if (at < 1) throw new AgentError("invalid", "label must be key=value")
|
|
306
|
+
return [label.slice(0, at), label.slice(at + 1)] as const
|
|
307
|
+
})
|
|
308
|
+
return (row, view) => {
|
|
309
|
+
const { root, name } = projectOf(row.session)
|
|
310
|
+
if (project !== undefined && root !== project && name.toLowerCase() !== project.toLowerCase()) return false
|
|
311
|
+
if (states?.length && !states.includes(view.state)) return false
|
|
312
|
+
if (machineIDs && !machineIDs.has(row.machineID)) return false
|
|
313
|
+
return labels.every(([key, value]) => view.labels[key] === value)
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const NO_STORE = { "cache-control": "no-store" }
|
|
318
|
+
const ok = (body: unknown) => Response.json(body, { headers: NO_STORE })
|
|
319
|
+
const READ = { session: true, scope: "sessions:read" } as const
|
|
320
|
+
|
|
321
|
+
export function readRoutes(deps: ReadDeps): AgentRoute[] {
|
|
322
|
+
return [
|
|
323
|
+
{
|
|
324
|
+
method: "GET",
|
|
325
|
+
path: `${AGENT_PREFIX}/status`,
|
|
326
|
+
allow: READ,
|
|
327
|
+
handle: async ({ user, actor }) => ok(snapshot(deps, user.id, actor, await labelsOf(deps, user.id))),
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
method: "GET",
|
|
331
|
+
path: `${AGENT_PREFIX}/sessions`,
|
|
332
|
+
allow: READ,
|
|
333
|
+
handle: async ({ user, url }) => {
|
|
334
|
+
const keep = sessionFilter(deps, user.id, url.searchParams)
|
|
335
|
+
const entries = directoryOf(deps.directory, user.id)
|
|
336
|
+
const labels = await labelsOf(deps, user.id)
|
|
337
|
+
const sessions = rows(deps.registry, user.id)
|
|
338
|
+
.map((row) => ({ row, view: view(row, entries.get(row.session.id), labels) }))
|
|
339
|
+
.filter(({ row, view }) => keep(row, view))
|
|
340
|
+
.map(({ row, view }) => ({ ...view, machineID: row.machineID, project: projectOf(row.session).name }))
|
|
341
|
+
return ok({ sessions })
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
{
|
|
345
|
+
method: "GET",
|
|
346
|
+
path: `${AGENT_PREFIX}/sessions/:addr`,
|
|
347
|
+
allow: READ,
|
|
348
|
+
handle: async ({ user, params }) => ok(await show(deps, user.id, params.addr!)),
|
|
349
|
+
},
|
|
350
|
+
{
|
|
351
|
+
method: "GET",
|
|
352
|
+
path: `${AGENT_PREFIX}/sessions/:addr/log`,
|
|
353
|
+
allow: READ,
|
|
354
|
+
handle: async ({ user, params, url }) => ok(await log(deps, user.id, params.addr!, url.searchParams.get("n"))),
|
|
355
|
+
},
|
|
356
|
+
...(deps.events ? [watchRoute(deps, deps.events)] : []),
|
|
357
|
+
]
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** GET /api/agent/watch: the journal's events live (watch.ts), with the events route's visibility and filters. */
|
|
361
|
+
function watchRoute(deps: ReadDeps, events: EventsRouteDeps): AgentRoute {
|
|
362
|
+
return {
|
|
363
|
+
method: "GET",
|
|
364
|
+
path: `${AGENT_PREFIX}/watch`,
|
|
365
|
+
allow: READ,
|
|
366
|
+
handle: ({ user, url, request, actor }) => {
|
|
367
|
+
const addr = url.searchParams.get("session") || undefined
|
|
368
|
+
const sessions = addr ? new Set(deps.directory.resolve(user.id, addr).sessions.map((s) => s.sessionID)) : undefined
|
|
369
|
+
const blocked = ["1", "true"].includes(url.searchParams.get("blocked") ?? "")
|
|
370
|
+
const cursor = url.searchParams.get("cursor") || request.headers.get("last-event-id") || undefined
|
|
371
|
+
return watchStream({
|
|
372
|
+
events,
|
|
373
|
+
userID: user.id,
|
|
374
|
+
actor,
|
|
375
|
+
signal: request.signal,
|
|
376
|
+
...(blocked ? { types: ["blocker.*"] } : {}),
|
|
377
|
+
...(sessions ? { sessions } : {}),
|
|
378
|
+
...(cursor ? { cursor } : {}),
|
|
379
|
+
...(deps.heartbeatMs !== undefined ? { heartbeatMs: deps.heartbeatMs } : {}),
|
|
380
|
+
...(deps.now ? { now: deps.now } : {}),
|
|
381
|
+
...(deps.draining ? { draining: deps.draining } : {}),
|
|
382
|
+
})
|
|
383
|
+
},
|
|
384
|
+
}
|
|
385
|
+
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
// The /api/agent framework: its own route table (access "agent" in the hub's table), credential checks and error
|
|
2
|
+
// envelope. http.ts registers it once (agentApi) and sends every /api/agent request here; later tasks add routes
|
|
3
|
+
// to agentRoutes() and verifiers to agentVerifiers() without touching http.ts.
|
|
4
|
+
//
|
|
5
|
+
// Order for each request: cookie + a hub bearer together → 400 invalid (a foreign bearer is ignored); cookie writes need the same origin (bearer
|
|
6
|
+
// requests skip it) → 403 forbidden; credential → 401 unauthorized / token_expired; route → 404 not_found;
|
|
7
|
+
// route class (agentAllows) → 403 forbidden; then the handler, whose throws become the envelope (agentFailure).
|
|
8
|
+
// Path parameters address sessions as `:addr` (resolved per caller), never `:session`.
|
|
9
|
+
import type { AgentEvents } from "./events"
|
|
10
|
+
import type { CursorKeys } from "./event-cursor"
|
|
11
|
+
import { eventsRoutes } from "./events-routes"
|
|
12
|
+
import type { DeviceRecord, Store, UserRecord } from "../store"
|
|
13
|
+
import type { Directory } from "./directory"
|
|
14
|
+
import { directoryRoutes } from "./directory-routes"
|
|
15
|
+
import { type ReadDeps, readRoutes, type ReadRegistry, type SpokeRequester } from "./read-routes"
|
|
16
|
+
import { agentAllows, type AgentActor, type AgentAllow, type BearerVerifier, type Credential, type Resolver } from "./actor"
|
|
17
|
+
import { appTokenRoutes, appTokenVerifier } from "./app-tokens"
|
|
18
|
+
import type { Decisions } from "./decisions"
|
|
19
|
+
import type { Delivery } from "./delivery"
|
|
20
|
+
import { MessageFeed, messageRoutes } from "./message-routes"
|
|
21
|
+
import type { SessionTokens } from "./session-tokens"
|
|
22
|
+
import { AgentError, agentFailure } from "./errors"
|
|
23
|
+
import { type AgentAudit, auditRoutes } from "./audit"
|
|
24
|
+
import { grantRoutes } from "./grant-routes"
|
|
25
|
+
import type { GrantRequests } from "./grant-requests"
|
|
26
|
+
import type { Grants } from "./grants"
|
|
27
|
+
import { type ActingSwitch, actingSwitchRoutes } from "./acting-switch"
|
|
28
|
+
import { Acting, type ActingRegistry, type Escalate } from "./acting"
|
|
29
|
+
import { actingRoutes } from "./acting-routes"
|
|
30
|
+
import type { Actions } from "../actions"
|
|
31
|
+
import { launchRoutes, type RouteLaunches } from "./launch-routes"
|
|
32
|
+
import { type RouteWebhooks, webhookRoutes } from "./webhook-routes"
|
|
33
|
+
|
|
34
|
+
export const AGENT_PREFIX = "/api/agent"
|
|
35
|
+
|
|
36
|
+
export const isAgentPath = (pathname: string): boolean => pathname === AGENT_PREFIX || pathname.startsWith(`${AGENT_PREFIX}/`)
|
|
37
|
+
|
|
38
|
+
export interface AgentContext {
|
|
39
|
+
readonly request: Request
|
|
40
|
+
readonly url: URL
|
|
41
|
+
readonly params: Record<string, string>
|
|
42
|
+
readonly actor: AgentActor
|
|
43
|
+
readonly credential: Credential
|
|
44
|
+
readonly user: UserRecord
|
|
45
|
+
/** The caller's device (user actors only). */
|
|
46
|
+
readonly device?: DeviceRecord
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface AgentRoute {
|
|
50
|
+
readonly method: string
|
|
51
|
+
/** Express-style path under /api/agent, e.g. /api/agent/sessions/:addr. */
|
|
52
|
+
readonly path: string
|
|
53
|
+
/** Who besides user actors may call it (default: users only, e.g. token management). */
|
|
54
|
+
readonly allow: AgentAllow
|
|
55
|
+
handle(context: AgentContext): Response | Promise<Response>
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** What the agent API needs from the hub. */
|
|
59
|
+
export interface AgentDeps {
|
|
60
|
+
readonly store: Store
|
|
61
|
+
/** Hub-minted session tokens (P1-T5). */
|
|
62
|
+
readonly sessionTokens?: SessionTokens
|
|
63
|
+
/** Names, roles and address resolution (P1-T8); its routes are added when given. */
|
|
64
|
+
readonly directory?: Directory
|
|
65
|
+
/** The hub's catalog (P1-T9): the read routes are added when given with the directory. */
|
|
66
|
+
readonly registry?: ReadRegistry & ActingRegistry
|
|
67
|
+
/** Hub → spoke requests (show, log). */
|
|
68
|
+
readonly spokes?: SpokeRequester
|
|
69
|
+
/** Agent messages (P2-T5): the message routes are added when given with the directory. */
|
|
70
|
+
readonly delivery?: Delivery
|
|
71
|
+
/** Decisions (ask @me, P2-T9): with the message routes. */
|
|
72
|
+
readonly decisions?: Decisions
|
|
73
|
+
/** The agent audit (P3-T5): GET /api/agent/audit when given. */
|
|
74
|
+
readonly audit?: AgentAudit
|
|
75
|
+
/** Grants (P3-T2): the /api/agent/grants routes when given. */
|
|
76
|
+
readonly grants?: Grants
|
|
77
|
+
/** Grant requests (P3-T9): POST /api/agent/grants/requests with the grant routes. */
|
|
78
|
+
readonly grantRequests?: GrantRequests
|
|
79
|
+
/** Kill switches (P3-T4): GET/PUT /api/agent/acting when given; the acting verbs check it. */
|
|
80
|
+
readonly acting?: ActingSwitch
|
|
81
|
+
/**
|
|
82
|
+
* The shared action executor (P3-T6): with acting, grants, audit, registry, directory and spokes, the act verbs
|
|
83
|
+
* POST /api/agent/sessions/:addr/{approve,deny,answer,prompt,stop} (P3-T7).
|
|
84
|
+
*/
|
|
85
|
+
readonly actions?: Actions
|
|
86
|
+
/**
|
|
87
|
+
* Launches (P4-T8): with grants, acting and audit, the launch routes /api/agent/launches (P4-T9); the read
|
|
88
|
+
* routes show the labels of launched sessions.
|
|
89
|
+
*/
|
|
90
|
+
readonly launches?: RouteLaunches
|
|
91
|
+
/** Webhooks (P4-T10): the /api/agent/webhooks routes when given. */
|
|
92
|
+
readonly webhooks?: RouteWebhooks
|
|
93
|
+
/** The canonical agent event journal: archive changes are appended to it. */
|
|
94
|
+
readonly agentEvents?: Pick<AgentEvents, "append" | "latest" | "since" | "appended">
|
|
95
|
+
/** Signing keys of GET /api/agent/events cursors; the events route is served when given with agentEvents. */
|
|
96
|
+
readonly eventCursorKeys?: CursorKeys
|
|
97
|
+
/** needs_user escalations of the act verbs (P3-T8 opens a Decision); without it needs_user is only answered. */
|
|
98
|
+
readonly escalate?: Escalate
|
|
99
|
+
/** The console's prompt limit, for the user's prompts on the act route. */
|
|
100
|
+
readonly maxPromptChars?: number
|
|
101
|
+
/** Stored messages as they are sent (the hub also streams them to the console); a new feed when absent. */
|
|
102
|
+
readonly feed?: MessageFeed
|
|
103
|
+
/** Heartbeat of GET /api/agent/watch. */
|
|
104
|
+
readonly heartbeatMs?: number
|
|
105
|
+
/** Aborted when the hub drains: watch streams end with a `retry:` hint. */
|
|
106
|
+
readonly draining?: AbortSignal
|
|
107
|
+
readonly now?: () => number
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Session and app token verifiers (P1-T5 adds the session token verifier, P1-T7 the app token one). Tried in
|
|
112
|
+
* order after the device lookup; see BearerVerifier.
|
|
113
|
+
*/
|
|
114
|
+
export function agentVerifiers(deps: AgentDeps): BearerVerifier[] {
|
|
115
|
+
return [...(deps.sessionTokens ? [deps.sessionTokens.verifier] : []), appTokenVerifier(deps.store)]
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const userView = (user: UserRecord) => ({
|
|
119
|
+
id: user.id,
|
|
120
|
+
...(user.name ? { name: user.name } : {}),
|
|
121
|
+
...(user.email ? { email: user.email } : {}),
|
|
122
|
+
role: user.role,
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
/** The built-in /api/agent routes. */
|
|
126
|
+
export function agentRoutes(deps: AgentDeps): AgentRoute[] {
|
|
127
|
+
const feed = deps.feed ?? new MessageFeed()
|
|
128
|
+
return [
|
|
129
|
+
...(deps.directory ? directoryRoutes({ directory: deps.directory, store: deps.store, ...(deps.agentEvents ? { events: deps.agentEvents } : {}), ...(deps.registry ? { registry: deps.registry } : {}), ...(deps.grants ? { grants: deps.grants } : {}), ...(deps.acting ? { acting: deps.acting } : {}), ...(deps.audit ? { audit: deps.audit } : {}), ...(deps.eventCursorKeys ? { previewKeys: deps.eventCursorKeys } : {}) }) : []),
|
|
130
|
+
...(deps.directory && deps.agentEvents && deps.eventCursorKeys ? eventsRoutes({ events: deps.agentEvents, keys: deps.eventCursorKeys, directory: deps.directory }) : []),
|
|
131
|
+
...(deps.directory && deps.delivery ? messageRoutes({ delivery: deps.delivery, directory: deps.directory, store: deps.store, feed, ...(deps.decisions ? { decisions: deps.decisions } : {}) }) : []),
|
|
132
|
+
...(deps.directory && deps.registry ? readRoutes({ store: deps.store, directory: deps.directory, registry: deps.registry, ...(deps.spokes ? { spokes: deps.spokes } : {}), ...launchReads(deps), ...(deps.agentEvents && deps.eventCursorKeys ? { events: { events: deps.agentEvents, keys: deps.eventCursorKeys, directory: deps.directory } } : {}), ...(deps.heartbeatMs !== undefined ? { heartbeatMs: deps.heartbeatMs } : {}), ...(deps.now ? { now: deps.now } : {}), ...(deps.draining ? { draining: deps.draining } : {}) }) : []),
|
|
133
|
+
{
|
|
134
|
+
method: "GET",
|
|
135
|
+
path: `${AGENT_PREFIX}/whoami`,
|
|
136
|
+
allow: { session: true, app: true },
|
|
137
|
+
handle: ({ actor, user }) => Response.json({ actor, user: userView(user) }, { headers: { "cache-control": "no-store" } }),
|
|
138
|
+
},
|
|
139
|
+
...appTokenRoutes(deps.store),
|
|
140
|
+
...(deps.audit ? auditRoutes(deps.audit) : []),
|
|
141
|
+
...(deps.grants ? grantRoutes(deps.grants, deps.now, deps.grantRequests) : []),
|
|
142
|
+
...(deps.acting ? actingSwitchRoutes(deps.acting) : []),
|
|
143
|
+
...actVerbRoutes(deps),
|
|
144
|
+
...(deps.launches && deps.grants && deps.acting && deps.audit
|
|
145
|
+
? launchRoutes({ launches: deps.launches, grants: deps.grants, acting: deps.acting, audit: deps.audit, ...(deps.directory ? { directory: deps.directory } : {}) })
|
|
146
|
+
: []),
|
|
147
|
+
...(deps.webhooks ? webhookRoutes(deps.webhooks, deps.store) : []),
|
|
148
|
+
]
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** What the read routes show of launches: launched sessions' labels (the latest launch wins). */
|
|
152
|
+
function launchReads(deps: AgentDeps): Pick<ReadDeps, "labels"> {
|
|
153
|
+
const { launches } = deps
|
|
154
|
+
return {
|
|
155
|
+
...(launches
|
|
156
|
+
? {
|
|
157
|
+
labels: async (userID: string) => {
|
|
158
|
+
const out = new Map<string, Readonly<Record<string, string>>>()
|
|
159
|
+
// Oldest first: a later launch of the same session overrides.
|
|
160
|
+
for (const launch of await launches.list(userID)) if (launch.sessionID !== undefined) out.set(launch.sessionID, launch.spec.labels)
|
|
161
|
+
return out
|
|
162
|
+
},
|
|
163
|
+
}
|
|
164
|
+
: {}),
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The act verbs, when every part they need is given. */
|
|
169
|
+
function actVerbRoutes(deps: AgentDeps): AgentRoute[] {
|
|
170
|
+
const { actions, acting, grants, audit, registry, directory, spokes } = deps
|
|
171
|
+
if (!actions || !acting || !grants || !audit || !registry || !directory || !spokes) return []
|
|
172
|
+
return actingRoutes(
|
|
173
|
+
new Acting({
|
|
174
|
+
registry,
|
|
175
|
+
directory,
|
|
176
|
+
actions,
|
|
177
|
+
spokes,
|
|
178
|
+
grants,
|
|
179
|
+
acting,
|
|
180
|
+
audit,
|
|
181
|
+
...(deps.escalate ? { escalate: deps.escalate } : {}),
|
|
182
|
+
...(deps.maxPromptChars !== undefined ? { maxPromptChars: deps.maxPromptChars } : {}),
|
|
183
|
+
}),
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export interface AgentApiOptions {
|
|
188
|
+
readonly resolve: Resolver
|
|
189
|
+
readonly cookieName: string
|
|
190
|
+
/** CSRF guard for cookie writes (the hub's same-origin check). */
|
|
191
|
+
sameOrigin(request: Request): boolean
|
|
192
|
+
/** Records activity of a resolved caller (device and user lastSeen). */
|
|
193
|
+
touch?(user: UserRecord, device?: DeviceRecord): void
|
|
194
|
+
readonly routes: readonly AgentRoute[]
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export interface AgentApi {
|
|
198
|
+
/** The route table (method, path) for the hub's audit table, as access "agent". */
|
|
199
|
+
readonly routes: readonly { readonly method: string; readonly path: string }[]
|
|
200
|
+
handle(request: Request, url: URL): Promise<Response>
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
interface Compiled {
|
|
204
|
+
readonly route: AgentRoute
|
|
205
|
+
readonly pattern: RegExp
|
|
206
|
+
readonly keys: string[]
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const MIXED = "Send the device cookie or a bearer token, not both"
|
|
210
|
+
|
|
211
|
+
export function agentApi(options: AgentApiOptions): AgentApi {
|
|
212
|
+
const compiled: Compiled[] = options.routes.map((route) => {
|
|
213
|
+
if (!isAgentPath(route.path) || route.path.includes(":session")) throw new Error(`agent route ${route.path}: must be under ${AGENT_PREFIX} and use :addr, not :session`)
|
|
214
|
+
const keys: string[] = []
|
|
215
|
+
const pattern = new RegExp(`^${route.path.replace(/:([a-z]+)/g, (_, key: string) => (keys.push(key), "([^/]+)"))}$`)
|
|
216
|
+
return { route, pattern, keys }
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
const find = (method: string, pathname: string) => {
|
|
220
|
+
for (const { route, pattern, keys } of compiled) {
|
|
221
|
+
if (route.method !== method) continue
|
|
222
|
+
const match = pattern.exec(pathname)
|
|
223
|
+
if (!match) continue
|
|
224
|
+
const params: Record<string, string> = {}
|
|
225
|
+
keys.forEach((key, i) => {
|
|
226
|
+
try {
|
|
227
|
+
params[key] = decodeURIComponent(match[i + 1]!)
|
|
228
|
+
} catch {
|
|
229
|
+
throw new AgentError("invalid", "Bad path")
|
|
230
|
+
}
|
|
231
|
+
})
|
|
232
|
+
return { route, params }
|
|
233
|
+
}
|
|
234
|
+
return undefined
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function handle(request: Request, url: URL): Promise<Response> {
|
|
238
|
+
try {
|
|
239
|
+
const mode = await options.resolve.mode(request)
|
|
240
|
+
if (mode === "mixed") throw new AgentError("invalid", MIXED)
|
|
241
|
+
const method = request.method
|
|
242
|
+
if (mode !== "bearer" && method !== "GET" && method !== "HEAD" && !options.sameOrigin(request)) {
|
|
243
|
+
throw new AgentError("forbidden", "Cross-origin request refused")
|
|
244
|
+
}
|
|
245
|
+
const resolved = await options.resolve(request)
|
|
246
|
+
if (!resolved.ok) {
|
|
247
|
+
if (resolved.reason === "expired") throw new AgentError("token_expired", "Token expired", { hint: "Get a new token" })
|
|
248
|
+
throw new AgentError("unauthorized", resolved.bearer ? "Invalid or revoked token" : "Sign in or send a bearer token")
|
|
249
|
+
}
|
|
250
|
+
options.touch?.(resolved.user, resolved.device)
|
|
251
|
+
const found = find(method, url.pathname)
|
|
252
|
+
if (!found) throw new AgentError("not_found", "Not found")
|
|
253
|
+
if (!agentAllows(found.route.allow, resolved.actor)) throw new AgentError("forbidden", "This credential cannot use this route")
|
|
254
|
+
const { actor, credential, user, device } = resolved
|
|
255
|
+
return await found.route.handle({ request, url, params: found.params, actor, credential, user, ...(device ? { device } : {}) })
|
|
256
|
+
} catch (cause) {
|
|
257
|
+
return agentFailure(cause)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return { routes: options.routes.map(({ method, path }) => ({ method, path })), handle }
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Oldest client (native app, CLI) this hub still serves; raised when the API breaks old clients. */
|
|
265
|
+
export const MIN_CLIENT_VERSION = "0.0.0"
|
|
266
|
+
|
|
267
|
+
export interface WellKnownInfo {
|
|
268
|
+
readonly publicURL: string
|
|
269
|
+
readonly version: string
|
|
270
|
+
readonly auth: { readonly mode: "pairing" } | { readonly mode: "oidc"; readonly issuer: string }
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** GET /.well-known/hub: public discovery and version gating for apps and CLIs (design §14.5). */
|
|
274
|
+
export function wellKnown(info: WellKnownInfo): Response {
|
|
275
|
+
// Pairing codes also work on an OIDC hub (a signed-in device issues them).
|
|
276
|
+
const auth = info.auth.mode === "oidc" ? { modes: ["oidc", "pairing"], oidc: { issuer: info.auth.issuer } } : { modes: ["pairing"] }
|
|
277
|
+
return Response.json(
|
|
278
|
+
{ publicURL: info.publicURL, version: info.version, minClientVersion: MIN_CLIENT_VERSION, auth, features: [] },
|
|
279
|
+
{ headers: { "cache-control": "public, max-age=60" } },
|
|
280
|
+
)
|
|
281
|
+
}
|