@huynle/hub 0.7.4 → 0.8.1
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 +430 -1
- package/Dockerfile +7 -2
- package/README.md +230 -32
- package/package.json +5 -3
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/daemon.ts +8 -1
- package/packages/claude-code/src/launcher.ts +29 -9
- package/packages/claude-code/src/paths.ts +3 -3
- package/packages/claude-code/src/profile.ts +173 -0
- package/packages/claude-code/src/sessions.ts +22 -4
- package/packages/cli/dist/hub-cli.js +6871 -3460
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
- package/packages/cli/src/artifacts-mcp.ts +160 -0
- package/packages/cli/src/bootstrap/adapters/node-services/render.ts +2 -2
- package/packages/cli/src/bootstrap/discovery/host.ts +15 -1
- package/packages/cli/src/bootstrap/discovery/probe.ts +1 -1
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +21 -9
- package/packages/cli/src/bootstrap/enrollment/policy.ts +138 -12
- package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
- package/packages/cli/src/cli.ts +96 -14
- package/packages/cli/src/format.ts +48 -10
- package/packages/cli/src/http.ts +2 -0
- package/packages/cli/src/intents.ts +23 -0
- package/packages/cli/src/launches.ts +9 -3
- package/packages/cli/src/login.ts +20 -6
- package/packages/cli/src/mcp.ts +385 -10
- package/packages/cli/src/messaging.ts +7 -3
- package/packages/cli/src/profiles-mcp.ts +301 -0
- package/packages/cli/src/setup/commands.ts +23 -4
- package/packages/cli/src/setup/runtime.ts +3 -2
- package/packages/cli/src/spoke-services.ts +1 -1
- package/packages/cli/src/team-intake-mcp.ts +158 -0
- package/packages/cli/src/teams-mcp.ts +460 -0
- package/packages/cli/src/watchlist.ts +135 -0
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/paths.ts +3 -3
- package/packages/codex/src/profile.ts +163 -0
- package/packages/codex/src/sessions.ts +12 -0
- package/packages/codex/src/spoke.ts +19 -1
- package/packages/node/package.json +1 -1
- package/packages/node/src/config.ts +104 -2
- package/packages/node/src/opencode-warm.ts +344 -0
- package/packages/node/src/run.ts +28 -1
- package/packages/preset/package.json +1 -1
- package/packages/preset/src/profile.ts +34 -0
- package/packages/preset/src/spoke.ts +4 -0
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/artifacts.ts +142 -0
- package/packages/protocol/src/concierge.ts +366 -0
- package/packages/protocol/src/contracts.ts +104 -0
- package/packages/protocol/src/graph.ts +70 -0
- package/packages/protocol/src/hub-workspace.ts +201 -0
- package/packages/protocol/src/index.ts +175 -6
- package/packages/protocol/src/launch.ts +36 -0
- package/packages/protocol/src/profiles.ts +301 -0
- package/packages/protocol/src/teams.ts +204 -0
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/address.ts +50 -2
- package/packages/sdk/src/artifact-jobs.ts +194 -0
- package/packages/sdk/src/artifacts.ts +187 -0
- package/packages/sdk/src/client.ts +226 -5
- package/packages/sdk/src/concierge.ts +88 -0
- package/packages/sdk/src/errors.ts +4 -1
- package/packages/sdk/src/index.ts +6 -0
- package/packages/sdk/src/team-intake.ts +122 -0
- package/packages/sdk/src/teams.ts +346 -0
- package/packages/sdk/src/types.ts +568 -1
- package/packages/sdk/src/wake.ts +20 -0
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/index.ts +1 -0
- package/packages/spoke-sdk/src/profile-apply.ts +245 -0
- package/src/central/actions.ts +7 -0
- package/src/central/agent/acting-routes.ts +2 -1
- package/src/central/agent/acting-switch.ts +2 -1
- package/src/central/agent/acting.ts +218 -10
- package/src/central/agent/app-tokens.ts +3 -2
- package/src/central/agent/audit.ts +18 -1
- package/src/central/agent/body.ts +163 -0
- package/src/central/agent/decisions.ts +125 -2
- package/src/central/agent/delivery.ts +194 -40
- package/src/central/agent/directory-routes.ts +37 -7
- package/src/central/agent/directory.ts +70 -14
- package/src/central/agent/errors.ts +7 -0
- package/src/central/agent/escalation.ts +10 -2
- package/src/central/agent/events-routes.ts +21 -4
- package/src/central/agent/events.ts +18 -4
- package/src/central/agent/form-check.ts +85 -0
- package/src/central/agent/grant-requests.ts +2 -1
- package/src/central/agent/grant-routes.ts +4 -3
- package/src/central/agent/grants.ts +29 -2
- package/src/central/agent/idempotency.ts +133 -0
- package/src/central/agent/intent-routes.ts +116 -0
- package/src/central/agent/intents.ts +505 -0
- package/src/central/agent/interaction-graph-routes.ts +212 -0
- package/src/central/agent/interaction-graph.ts +865 -0
- package/src/central/agent/launch-engine.ts +189 -32
- package/src/central/agent/launch-routes.ts +26 -10
- package/src/central/agent/launch-spec.ts +40 -3
- package/src/central/agent/launch-store.ts +55 -2
- package/src/central/agent/message-limits.ts +89 -11
- package/src/central/agent/message-routes.ts +10 -4
- package/src/central/agent/messages.ts +28 -2
- package/src/central/agent/names.ts +12 -2
- package/src/central/agent/placement.ts +93 -18
- package/src/central/agent/read-routes.ts +89 -19
- package/src/central/agent/routes.ts +178 -5
- package/src/central/agent/webhook-routes.ts +2 -1
- package/src/central/agent/webhooks.ts +17 -2
- package/src/central/agent-data.ts +4 -0
- package/src/central/artifacts/access.ts +44 -0
- package/src/central/artifacts/blob-store.ts +113 -0
- package/src/central/artifacts/blob-support.ts +98 -0
- package/src/central/artifacts/events.ts +27 -0
- package/src/central/artifacts/host-caps.ts +162 -0
- package/src/central/artifacts/host.ts +315 -0
- package/src/central/artifacts/idempotency.ts +109 -0
- package/src/central/artifacts/jobs-routes.ts +776 -0
- package/src/central/artifacts/jobs.ts +344 -0
- package/src/central/artifacts/maintenance.ts +103 -0
- package/src/central/artifacts/routes.ts +360 -0
- package/src/central/artifacts/store.ts +776 -0
- package/src/central/artifacts/workspaces.ts +31 -0
- package/src/central/backend/file-blobs.ts +377 -0
- package/src/central/backend/index.ts +12 -3
- package/src/central/backend/memory-blobs.ts +116 -0
- package/src/central/backend/postgres-blobs.ts +513 -0
- package/src/central/backend/postgres-migrate.ts +33 -0
- package/src/central/backend/postgres-state.ts +19 -0
- package/src/central/backend/types.ts +8 -0
- package/src/central/concierge/activity.ts +142 -0
- package/src/central/concierge/arm.ts +63 -0
- package/src/central/concierge/board-render.ts +190 -0
- package/src/central/concierge/board-results.ts +169 -0
- package/src/central/concierge/board-routes.ts +350 -0
- package/src/central/concierge/board.ts +249 -0
- package/src/central/concierge/context.ts +221 -0
- package/src/central/concierge/entitlements.ts +13 -0
- package/src/central/concierge/guard.ts +120 -0
- package/src/central/concierge/inbound.ts +86 -0
- package/src/central/concierge/index.ts +84 -0
- package/src/central/concierge/notes.ts +41 -0
- package/src/central/concierge/notifier.ts +986 -0
- package/src/central/concierge/prompt.ts +131 -0
- package/src/central/concierge/proposals.ts +268 -0
- package/src/central/concierge/push-remap.ts +49 -0
- package/src/central/concierge/routes.ts +122 -0
- package/src/central/concierge/schedule.ts +113 -0
- package/src/central/concierge/service.ts +243 -0
- package/src/central/concierge/session-routes.ts +125 -0
- package/src/central/concierge/settings.ts +217 -0
- package/src/central/concierge/supervisor.ts +767 -0
- package/src/central/concierge/tiers.ts +260 -0
- package/src/central/concierge/watchlist-routes.ts +279 -0
- package/src/central/concierge/watchlist.ts +273 -0
- package/src/central/config.ts +28 -6
- package/src/central/http.ts +660 -54
- package/src/central/main.ts +1 -1
- package/src/central/mcp-http.ts +10 -21
- package/src/central/profiles/apply.ts +333 -0
- package/src/central/profiles/content.ts +174 -0
- package/src/central/profiles/events.ts +40 -0
- package/src/central/profiles/idempotency.ts +14 -0
- package/src/central/profiles/import.ts +139 -0
- package/src/central/profiles/routes.ts +356 -0
- package/src/central/profiles/store.ts +597 -0
- package/src/central/profiles/usage-ports.ts +120 -0
- package/src/central/profiles/usage.ts +364 -0
- package/src/central/push.ts +7 -0
- package/src/central/registry.ts +69 -7
- package/src/central/store.ts +4 -0
- package/src/central/teams/collections.ts +34 -0
- package/src/central/teams/discovery.ts +346 -0
- package/src/central/teams/draft-edits.ts +46 -0
- package/src/central/teams/events.ts +46 -0
- package/src/central/teams/intake-routes.ts +100 -0
- package/src/central/teams/intake.ts +786 -0
- package/src/central/teams/policy.ts +231 -0
- package/src/central/teams/routes.ts +516 -0
- package/src/central/teams/seats.ts +272 -0
- package/src/central/teams/staffing.ts +910 -0
- package/src/central/teams/store.ts +667 -0
- package/src/central/teams/validation.ts +256 -0
- package/src/central/workspaces/board.ts +408 -0
- package/src/central/workspaces/checkpoints.ts +316 -0
- package/src/central/workspaces/coordinator.ts +841 -0
- package/src/central/workspaces/events.ts +85 -0
- package/src/central/workspaces/lifecycle.ts +93 -0
- package/src/central/workspaces/master.ts +490 -0
- package/src/central/workspaces/members.ts +632 -0
- package/src/central/workspaces/routes.ts +354 -0
- package/src/central/workspaces/sidechats-routes.ts +410 -0
- package/src/central/workspaces/sidechats.ts +482 -0
- package/src/central/workspaces/store.ts +571 -0
- package/src/central/workspaces/supervision.ts +355 -0
- package/src/index.ts +126 -23
- package/src/migrate.ts +109 -3
- package/src/spoke/concierge-agent.ts +33 -0
- package/src/spoke/deliver.ts +22 -4
- package/src/spoke/env-inject.ts +8 -1
- package/src/spoke/fork-revert.ts +142 -0
- package/src/spoke/hub-rpc.ts +41 -5
- package/src/spoke/hub-tool.ts +404 -25
- package/src/spoke/launchers.ts +6 -4
- package/src/spoke/listing.ts +65 -10
- package/src/spoke/localhub.ts +14 -4
- package/src/spoke/locations.ts +40 -0
- package/src/spoke/profile-apply.ts +298 -0
- package/src/spoke/session-context.ts +256 -0
- package/src/spoke/spoke.ts +330 -23
- package/src/spoke/timeline.ts +6 -1
- package/src/spoke/tracker.ts +23 -1
- package/src/spoke/who-teams.ts +81 -0
- package/src/tui-messaging.ts +32 -18
- package/web/dist/assets/ArtifactPage-mNzjlP_U.js +1 -0
- package/web/dist/assets/ArtifactPane-CPQcuX7c.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
- package/web/dist/assets/ArtifactWorkbench-NoFC5Sb2.js +6 -0
- package/web/dist/assets/Library-DYsFj_5c.css +1 -0
- package/web/dist/assets/Library-LXHsP5Fw.js +2 -0
- package/web/dist/assets/ProfilesDestination-DhVVcAep.js +1 -0
- package/web/dist/assets/TeamsPage-DBrO5hoL.js +1 -0
- package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
- package/web/dist/assets/Watchtower-DxaJYQp_.js +2 -0
- package/web/dist/assets/index-BabrNDB3.css +1 -0
- package/web/dist/assets/index-lIOfSYck.js +34 -0
- package/web/dist/assets/live-BDq0AABg.js +5 -0
- package/web/dist/assets/live-BUofsQpz.css +1 -0
- package/web/dist/assets/pane-layout-DWXfDU-1.js +1 -0
- package/web/dist/assets/plural-Bspvoqfq.js +1 -0
- package/web/dist/assets/renderer2d-DAr4IHM8.js +1 -0
- package/web/dist/assets/renderer3d-DWZj595u.js +5393 -0
- package/web/dist/assets/safe-area-JUb6OeI3.js +1 -0
- package/web/dist/assets/source-CDU04kYw.js +1 -0
- package/web/dist/assets/ui-Dh1-78Cl.js +3 -0
- package/web/dist/index.html +5 -2
- package/web/dist/sw.js +22 -2
- package/web/dist/assets/index-Dm8OmkaO.css +0 -1
- package/web/dist/assets/index-FUJbj_Du.js +0 -20
package/src/central/http.ts
CHANGED
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
// Phones authenticate with the `voice_device` cookie (or, in native apps, the same secret as a bearer device
|
|
3
3
|
// token; see ./agent/actor.ts); spokes with machine bearer tokens. Source IPs
|
|
4
4
|
// (including loopback) are never trusted.
|
|
5
|
+
import { BODY_LIMITS, readBounded, slowMessage } from "./agent/body"
|
|
5
6
|
import type { Server } from "bun"
|
|
6
7
|
import { diskAssets, type WebAssets } from "./assets"
|
|
7
8
|
import path from "node:path"
|
|
8
9
|
import { promisify } from "node:util"
|
|
9
10
|
import { brotliCompress, constants as zlib, gzip } from "node:zlib"
|
|
10
|
-
import { formAnswerProblem, PROMPT_IMAGE_MIMES, promptImagesProblem, type AvailableModel, type CoreOp, type EnrollStart, type FormAnswer, type ModelSelection, type PermissionDecision, type PromptImage } from "../protocol"
|
|
11
|
+
import { formAnswerProblem, PROMPT_IMAGE_MIMES, WORKSPACE_LIMITS, promptImagesProblem, type AvailableModel, type CoreOp, type EnrollStart, type FormAnswer, type ModelSelection, type PermissionDecision, type PromptImage } from "../protocol"
|
|
11
12
|
import { isLoopbackAddress, isLoopbackHost, isLoopbackURL, loopbackOrigins } from "../loopback"
|
|
12
13
|
import { speakable } from "../speakable"
|
|
13
14
|
import { Auth, RateLimitError, ValidationError } from "./auth"
|
|
@@ -17,7 +18,7 @@ import { isNotificationKind, Push, type PushSender } from "./push"
|
|
|
17
18
|
import { pushEndpointProblem, resolveHost as systemResolveHost, type ResolveHost } from "./push-target"
|
|
18
19
|
import { Webhooks } from "./agent/webhooks"
|
|
19
20
|
import { issueAppToken } from "./agent/app-tokens"
|
|
20
|
-
import { AGENT_EVENTS_AGE_MS, AGENT_EVENTS_MAX, AgentEvents, archiveEvent } from "./agent/events"
|
|
21
|
+
import { AGENT_EVENTS_AGE_MS, AGENT_EVENTS_MAX, AgentEvents, archiveEvent, conciergeEvent, type AgentEvent } from "./agent/events"
|
|
21
22
|
import { EventWriters } from "./agent/event-writers"
|
|
22
23
|
import { MemoryEventLog } from "./backend/memory-events"
|
|
23
24
|
import { WEBHOOK_SCOPE } from "./agent/webhook-routes"
|
|
@@ -26,8 +27,8 @@ import { createResolver, type AgentActor, type BearerVerifier, type CredentialMo
|
|
|
26
27
|
import { ActingSwitch } from "./agent/acting-switch"
|
|
27
28
|
import { AgentAudit } from "./agent/audit"
|
|
28
29
|
import { Grants } from "./agent/grants"
|
|
29
|
-
import { Decisions, type Decision } from "./agent/decisions"
|
|
30
|
-
import type
|
|
30
|
+
import { ANSWER_VIAS, type AnswerVia, Decisions, type Decision, TAP_ONLY } from "./agent/decisions"
|
|
31
|
+
import { Acting, type Escalate } from "./agent/acting"
|
|
31
32
|
import { Escalations } from "./agent/escalation"
|
|
32
33
|
import { GrantRequests } from "./agent/grant-requests"
|
|
33
34
|
import { partyFromActor } from "./agent/envelope"
|
|
@@ -36,6 +37,7 @@ import { AgentError, type AgentErrorCode } from "./agent/errors"
|
|
|
36
37
|
import type { Launch } from "./agent/launch-store"
|
|
37
38
|
import { decisionStateQuery, MessageFeed } from "./agent/message-routes"
|
|
38
39
|
import { hubDirectory } from "./agent/directory-routes"
|
|
40
|
+
import type { Directory } from "./agent/directory"
|
|
39
41
|
import { agentApi, agentRoutes, agentVerifiers, isAgentPath, wellKnown, type AgentRoute } from "./agent/routes"
|
|
40
42
|
import { MCP_PATH, mcpHttp } from "./mcp-http"
|
|
41
43
|
import { SessionTokens } from "./agent/session-tokens"
|
|
@@ -44,6 +46,7 @@ import { TokenKeys } from "./token-keys"
|
|
|
44
46
|
import { EventLog } from "./events-log"
|
|
45
47
|
import { BusyError, Registry } from "./registry"
|
|
46
48
|
import { createActions, type Unsupported, unsupportedMessage } from "./actions"
|
|
49
|
+
import { IntentRetention, SessionIntents, withPromptIntents } from "./agent/intents"
|
|
47
50
|
import { audioFormat, SpeechError } from "./speech/provider"
|
|
48
51
|
import { SpeechService } from "./speech/service"
|
|
49
52
|
import { NodeRegistry } from "./nodes"
|
|
@@ -57,6 +60,47 @@ import { randomBytes, randomUUID } from "node:crypto"
|
|
|
57
60
|
import { LaunchEngine } from "./agent/launch-engine"
|
|
58
61
|
import { LaunchTokens } from "./agent/launch-tokens"
|
|
59
62
|
import { LaunchFeed, launchInfo } from "./agent/launch-routes"
|
|
63
|
+
import { ProfileApplier } from "./profiles/apply"
|
|
64
|
+
import { ProfileStore } from "./profiles/store"
|
|
65
|
+
import { WorkspaceStore } from "./workspaces/store"
|
|
66
|
+
import { directoryPresence, MembershipStore } from "./workspaces/members"
|
|
67
|
+
import { SupervisionStore } from "./workspaces/supervision"
|
|
68
|
+
import { WorkspaceLifecycle } from "./workspaces/lifecycle"
|
|
69
|
+
import { registryBoardSessions } from "./workspaces/board"
|
|
70
|
+
import { CheckpointStore, journalCursors } from "./workspaces/checkpoints"
|
|
71
|
+
import { coordinatorActor, SupervisorCoordinator } from "./workspaces/coordinator"
|
|
72
|
+
import { artifactJournalEvent } from "./artifacts/events"
|
|
73
|
+
import { ArtifactMaintenance } from "./artifacts/maintenance"
|
|
74
|
+
import { ARTIFACT_UPLOAD_LIMITS } from "./artifacts/routes"
|
|
75
|
+
import { ArtifactStore } from "./artifacts/store"
|
|
76
|
+
import { artifactHost, isArtifactHostPath } from "./artifacts/host"
|
|
77
|
+
import { ArtifactHostCaps } from "./artifacts/host-caps"
|
|
78
|
+
import { artifactWorkspacesFromState } from "./artifacts/workspaces"
|
|
79
|
+
import { teamDeniedEvent, TeamPolicy } from "./teams/policy"
|
|
80
|
+
import { TeamSeatStore } from "./teams/seats"
|
|
81
|
+
import { TeamStaffingService } from "./teams/staffing"
|
|
82
|
+
import { intakeActor, TeamIntakeService } from "./teams/intake"
|
|
83
|
+
import { TeamStore } from "./teams/store"
|
|
84
|
+
import { ActivityTracker } from "./concierge/activity"
|
|
85
|
+
import { ResultTracker } from "./concierge/board-results"
|
|
86
|
+
import { BoardRevisions } from "./concierge/board"
|
|
87
|
+
import { conciergeBoard } from "./concierge/board-routes"
|
|
88
|
+
import { ContextPusher } from "./concierge/context"
|
|
89
|
+
import { C1_ENTITLEMENTS } from "./concierge/entitlements"
|
|
90
|
+
import { conciergeActor, conciergeInbound, conciergeProtects } from "./concierge/inbound"
|
|
91
|
+
import { conciergeModules, type ConciergePorts, type ConciergeTiers } from "./concierge/index"
|
|
92
|
+
import { watchedSessions } from "./concierge/notifier"
|
|
93
|
+
import { NotesStore } from "./concierge/notes"
|
|
94
|
+
import { conciergeSessionGate, timedIdentity } from "./concierge/session-routes"
|
|
95
|
+
import { conciergeAwareNotifier, isConciergeSession } from "./concierge/push-remap"
|
|
96
|
+
import { ConciergeService, type MachineFacts } from "./concierge/service"
|
|
97
|
+
import { machineModels } from "./concierge/routes"
|
|
98
|
+
import { SettingsStore } from "./concierge/settings"
|
|
99
|
+
import { CONCIERGE_TAMPERED, isConciergeLaunch, recordIdentity, StatusStore, Supervisor } from "./concierge/supervisor"
|
|
100
|
+
import { CONCIERGE_AGENT } from "../../packages/protocol/src/concierge"
|
|
101
|
+
import { CONCIERGE_FORCED_PUSH_MS, INTERNAL_AGENTS, KNOWN_PRIMARY_AGENTS } from "./concierge/context"
|
|
102
|
+
import { grantState } from "./agent/grants"
|
|
103
|
+
import { catalogHarnesses, TeamDiscovery } from "./teams/discovery"
|
|
60
104
|
import { DRAIN_RETRY_AFTER_MS, DRAIN_TIMEOUT_MS, RequestTimeoutError, RequestTooLargeError, SpokeError, SpokeHub, type DrainOptions, type Notifier, type SpokeHubOptions, type SpokeSocketData } from "./spoke-ws"
|
|
61
105
|
import { ACTIVITY_INTERVAL_MAX, ACTIVITY_INTERVAL_MIN, LOCAL_USER, PUSH_PREF_KEYS, quietHoursInput, Store, VIEW_PREF_KEYS, VIEW_PREF_MAX_ARCHIVED, type DeviceRecord, type LegacyPushPrefs, type PushPrefs, type UserRecord, type ViewPrefs } from "./store"
|
|
62
106
|
|
|
@@ -143,10 +187,18 @@ export interface Central {
|
|
|
143
187
|
readonly webhooks: Webhooks
|
|
144
188
|
/** The canonical agent event journal (GET /api/agent/events, MCP events): compact events per user, 24 h / 10,000. */
|
|
145
189
|
readonly agentEvents: AgentEvents
|
|
190
|
+
/** The agent directory (names, roles, address resolution). */
|
|
191
|
+
readonly directory: Directory
|
|
192
|
+
/** (CON-04) The Concierge service (settings, supervisor, context). */
|
|
193
|
+
readonly concierge: ConciergeService
|
|
146
194
|
/** The journal's writers (state, blockers, presence, messages, launches); settled() waits for queued appends. */
|
|
147
195
|
readonly agentEventWriters: EventWriters
|
|
196
|
+
/** (CON-02) Session intent retention (its leader runs it every 6 h; tests run a pass with runOnce). */
|
|
197
|
+
readonly intentRetention: IntentRetention
|
|
148
198
|
/** The JSON API's route table (method, path, access), for audits and tests. */
|
|
149
199
|
readonly routes: readonly RouteInfo[]
|
|
200
|
+
/** Teams (HWA-33..37): definitions, seats (the delivery policy's) and staffing; HWA-37 routes reuse these. */
|
|
201
|
+
readonly teams: { readonly store: TeamStore; readonly seats: TeamSeatStore; readonly staffing: TeamStaffingService; readonly intake: TeamIntakeService }
|
|
150
202
|
/**
|
|
151
203
|
* Draining before a shutdown (design §10; SIGTERM): /readyz fails, new /spoke/ws upgrades are refused, spokes and
|
|
152
204
|
* nodes get bye{reason:"draining", retryAfterMs} (once their requests in flight are answered), the leader leases
|
|
@@ -164,8 +216,8 @@ interface Listener {
|
|
|
164
216
|
readonly deviceID: string
|
|
165
217
|
/** Leaves out streamed text and reasoning (except of `session`'s tree). */
|
|
166
218
|
readonly lite: boolean
|
|
167
|
-
/** The
|
|
168
|
-
readonly
|
|
219
|
+
/** The sessions the page shows (one, or one per visible pane): their trees' text and reasoning stream even when lite. */
|
|
220
|
+
readonly sessions?: readonly string[]
|
|
169
221
|
/** Sends a message; `id` is its event id (EventSource's lastEventId). */
|
|
170
222
|
send(message: Message, id?: string): void
|
|
171
223
|
close(): void
|
|
@@ -176,6 +228,23 @@ interface Listener {
|
|
|
176
228
|
/** Messages a lite stream leaves out (unless they are of the listener's session tree). */
|
|
177
229
|
const STREAMED = new Set(["delta", "reasoning"])
|
|
178
230
|
|
|
231
|
+
/**
|
|
232
|
+
* The sessions a console stream shows (HWA-07 panes): `session` (the focused one; all an older console sends)
|
|
233
|
+
* then `sessions` (comma-separated, one per visible pane), each once, blanks ignored. At most
|
|
234
|
+
* WORKSPACE_LIMITS.panes; an ID over 300 characters or too many sessions is a problem (400), never truncated.
|
|
235
|
+
*/
|
|
236
|
+
function streamSessionsQuery(url: URL): { sessions: string[]; multi: boolean } | { problem: string } {
|
|
237
|
+
const listed = url.searchParams.getAll("sessions").flatMap((v) => v.split(","))
|
|
238
|
+
const sessions: string[] = []
|
|
239
|
+
for (const id of [url.searchParams.get("session") ?? "", ...listed]) {
|
|
240
|
+
if (!id || sessions.includes(id)) continue
|
|
241
|
+
if (id.length > 300) return { problem: "a session ID is too long" }
|
|
242
|
+
sessions.push(id)
|
|
243
|
+
}
|
|
244
|
+
if (sessions.length > WORKSPACE_LIMITS.panes) return { problem: `a stream shows at most ${WORKSPACE_LIMITS.panes} sessions` }
|
|
245
|
+
return { sessions, multi: url.searchParams.has("sessions") }
|
|
246
|
+
}
|
|
247
|
+
|
|
179
248
|
/**
|
|
180
249
|
* Who may call a route: anyone, any signed-in user, admins, or the owner of the :session / :machine in the path;
|
|
181
250
|
* "agent": an /api/agent route (src/central/agent), checked per credential kind there.
|
|
@@ -229,6 +298,10 @@ const LAUNCH_STOP_TIMEOUT_MS = 180_000
|
|
|
229
298
|
*/
|
|
230
299
|
export const TURN_ACCEPT_MS = 1500
|
|
231
300
|
const NO_OWNER = "No OpenCode process for this session is connected"
|
|
301
|
+
/** The spoke's refusal of a revert while the session runs (src/spoke/fork-revert.ts REVERT_BUSY). */
|
|
302
|
+
const REVERT_BUSY = "The session is working; wait for it to finish (or stop it) before reverting"
|
|
303
|
+
/** A message ID in a fork or revert request (as the spoke protocol bounds it). */
|
|
304
|
+
const MESSAGE_ID = /^[A-Za-z0-9_.:@/+~-]{1,300}$/
|
|
232
305
|
const AUTO_NAME_OUTDATED = "Update the Hub plugin to use Auto-name"
|
|
233
306
|
const NOT_EXPOSED = "Voice is not enabled for this session"
|
|
234
307
|
/** A permission or question that is not pending any more: someone answered it elsewhere, or it expired. */
|
|
@@ -257,7 +330,9 @@ const WEB_NOT_BUILT = [
|
|
|
257
330
|
/** Preview text for POST /api/settings/speech/test. */
|
|
258
331
|
const SPEECH_TEST_TEXT = "Hi! This is how I will sound when I read replies to you."
|
|
259
332
|
const SPEECH_TEST_MAX_CHARS = 300
|
|
260
|
-
|
|
333
|
+
// Primary-navigation destinations (web/src/app/nav.ts) and one sub-path level, plus legacy routes the console
|
|
334
|
+
// redirects (birds-eye → watchtower, nav.ts LEGACY_ROUTES); test/central-web.test.ts checks the registry.
|
|
335
|
+
const APP_ROUTES = /^\/(?:|pair|approve|new|settings(?:\/[a-z-]+)?|panes|concierge(?:\/[^/]+)?|s\/[^/]+|(?:artifacts|teams|watchtower|birds-eye|profiles)(?:\/[^/]+)?)$/
|
|
261
336
|
const STATIC_PATH = /^(?:\/[A-Za-z0-9_-][A-Za-z0-9._-]*)+$/
|
|
262
337
|
/** Where a one-time sign-in link may land: home, or a session (with Voice mode starting: `?voice=1`). */
|
|
263
338
|
const LOGIN_NEXT = /^\/(?:s\/[A-Za-z0-9_-]+(?:\?voice=1)?)?$/
|
|
@@ -302,16 +377,18 @@ class HttpError extends Error {
|
|
|
302
377
|
message: string,
|
|
303
378
|
/** Machine-readable reason, e.g. not_exposed. */
|
|
304
379
|
readonly code?: string,
|
|
380
|
+
/** Answer with Connection: close (a request body was left unread on the socket: agent/body.ts). */
|
|
381
|
+
readonly close?: boolean,
|
|
305
382
|
) {
|
|
306
383
|
super(message)
|
|
307
384
|
}
|
|
308
385
|
}
|
|
309
386
|
|
|
310
387
|
/** A core op the session's spoke does not serve (another harness's spoke): 409 code unsupported, like plugin_outdated. */
|
|
311
|
-
const unsupportedError = (lacking:
|
|
388
|
+
const unsupportedError = (lacking: { readonly feature: Unsupported["feature"] | "fork" | "revert"; readonly harness: string }) => new HttpError(409, unsupportedMessage(lacking), "unsupported")
|
|
312
389
|
|
|
313
|
-
const error = (status: number, message: string, code?: string) =>
|
|
314
|
-
Response.json({ error: message, ...(code ? { code } : {}) }, { status })
|
|
390
|
+
const error = (status: number, message: string, code?: string, headers?: Record<string, string>) =>
|
|
391
|
+
Response.json({ error: message, ...(code ? { code } : {}) }, { status, ...(headers ? { headers } : {}) })
|
|
315
392
|
|
|
316
393
|
export function startCentral(options: CentralOptions): Central {
|
|
317
394
|
const { config } = options
|
|
@@ -330,7 +407,17 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
330
407
|
const registry = new Registry(now, undefined, (machineID) => store.machines().find((m) => m.id === machineID)?.ownerID)
|
|
331
408
|
const nodes = new NodeRegistry((machineID) => store.machines().find((m) => m.id === machineID)?.ownerID, now)
|
|
332
409
|
const push = new Push({ store, subject: config.vapidSubject, sender: options.pushSender, kv: backends.kv })
|
|
333
|
-
|
|
410
|
+
// CON-04: the Concierge's state record is read early (the Concierge-only authority, hiding, the inbound rule and
|
|
411
|
+
// push remapping all follow it); the service itself is built below, once its parts exist.
|
|
412
|
+
const conciergeStatus = new StatusStore(backends.state, now)
|
|
413
|
+
// (FX-07) Beyond the past sessions the record keeps: those a machine reports locked (its spoke holds them as Concierge sessions).
|
|
414
|
+
const conciergeIdentity = recordIdentity(conciergeStatus, { reported: (userID) => registry.lockedSessions(userID) })
|
|
415
|
+
// (FX-07) The identity checks on request paths (route gate, manage guard, acting guard, inbound/outbound rules, team
|
|
416
|
+
// staffing, Decision and audit labels) wait at most IDENTITY_TIMEOUT_MS for the store: past it they refuse with busy
|
|
417
|
+
// (503), never hang; one shared breaker answers busy at once for a while after a read timed out.
|
|
418
|
+
const conciergeTimedIdentity = timedIdentity(conciergeIdentity)
|
|
419
|
+
// Pushes about the Concierge's hidden sessions become kind "concierge" (/concierge) or are dropped.
|
|
420
|
+
const notifier = conciergeAwareNotifier(options.notifier ?? push, () => conciergeIdentity)
|
|
334
421
|
/** SSE subscribers, one user each. */
|
|
335
422
|
const listeners = new Set<Listener>()
|
|
336
423
|
/** Recent messages per user, replayed to a stream that reconnects with Last-Event-ID. */
|
|
@@ -338,9 +425,10 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
338
425
|
/** Whether a listener wants a message (lite streams skip other sessions' streamed text). */
|
|
339
426
|
const wants = (listener: Listener, message: Message): boolean => {
|
|
340
427
|
if (!listener.lite || !STREAMED.has(message.type)) return true
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
428
|
+
return (listener.sessions ?? []).some((session) => {
|
|
429
|
+
const root = registry.status(session)?.[0]?.id ?? session
|
|
430
|
+
return message.sessionID === session || message.rootID === root || message.sessionID === root
|
|
431
|
+
})
|
|
344
432
|
}
|
|
345
433
|
/**
|
|
346
434
|
* Cluster mode (design §9): a user's events go to every pod over the Bus (user.<id>.live), logged in the shared
|
|
@@ -439,7 +527,11 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
439
527
|
hostname: config.host,
|
|
440
528
|
port: config.port,
|
|
441
529
|
idleTimeout: 255,
|
|
442
|
-
|
|
530
|
+
// Staged artifact blobs stream up to ARTIFACT_UPLOAD_LIMITS.blobBytes (the routes bound every body themselves).
|
|
531
|
+
// Plus headroom for the drain past a route's bound (BODY_LIMITS.drainBytes, overshot by up to one chunk): Bun ≥ 1.4
|
|
532
|
+
// enforces this limit on chunked bodies too, answering a bare 413 and closing the socket before the route can
|
|
533
|
+
// give its own 400/413. It stays a backstop; the routes' bounds decide.
|
|
534
|
+
maxRequestBodySize: Math.max(config.maxAudioBytes + 1024, ARTIFACT_UPLOAD_LIMITS.blobBytes + 65_536, 1 << 20) + 2 * BODY_LIMITS.drainBytes,
|
|
443
535
|
fetch: (request, server) => handle(request, server),
|
|
444
536
|
websocket: {
|
|
445
537
|
open: (ws) => spokes.websocket.open?.(ws),
|
|
@@ -536,7 +628,11 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
536
628
|
// Push goes to the session owner's devices only; an alert without an owner goes nowhere.
|
|
537
629
|
if (!alert.owner) return
|
|
538
630
|
// Activity updates are collected and throttled per device by the push layer.
|
|
539
|
-
if (alert.kind === "activity")
|
|
631
|
+
if (alert.kind === "activity") {
|
|
632
|
+
// The Concierge's own activity is never pushed (its session is hidden).
|
|
633
|
+
void isConciergeSession(conciergeIdentity, alert.owner, alert.sessionID).then((hidden) => (hidden ? undefined : push.activity(alert)))
|
|
634
|
+
return
|
|
635
|
+
}
|
|
540
636
|
const extra = {
|
|
541
637
|
...(alert.requestID ? { requestID: alert.requestID } : {}),
|
|
542
638
|
...(alert.subject ? { subject: alert.subject } : {}),
|
|
@@ -688,7 +784,25 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
688
784
|
}
|
|
689
785
|
// Agent messages (design §3): delivered through spokes that announce "deliver"; pending ones flush on every
|
|
690
786
|
// sessions frame; human turns (console turn/utterance, prompts typed in the harness) reset hop chains.
|
|
787
|
+
// Teams (HWA-33) and their communication policy in delivery (HWA-34): a denial is audited and journaled (IDs only).
|
|
788
|
+
let teamSeats: TeamSeatStore | undefined
|
|
789
|
+
const teamStore = new TeamStore({ state: backends.state, now, pinnedVersions: (userID, teamID) => teamSeats!.pinnedVersions(userID, teamID) })
|
|
790
|
+
const teamPresence = directoryPresence(directory, (userID) => store.agentData(userID).sessions ?? [])
|
|
791
|
+
teamSeats = new TeamSeatStore({ state: backends.state, teams: teamStore, now, knowsSession: async (userID, sessionID) => !["unknown", "deleted"].includes(await teamPresence(userID, sessionID)) })
|
|
792
|
+
const teamPolicy = new TeamPolicy({
|
|
793
|
+
teams: teamStore,
|
|
794
|
+
seats: teamSeats,
|
|
795
|
+
directory,
|
|
796
|
+
denied: async (userID, denial) => {
|
|
797
|
+
const target = denial.recipientID !== undefined ? { sessionID: denial.recipientID } : {}
|
|
798
|
+
const reason = `team policy: ${denial.reason} (${denial.kind})`
|
|
799
|
+
await Promise.all([audit.record(userID, { actor: { kind: "session", sessionID: denial.senderID }, verb: "team.denied", target, decision: "forbidden", reason, outcome: "refused" }), agentEvents.append(userID, teamDeniedEvent(denial))])
|
|
800
|
+
},
|
|
801
|
+
})
|
|
691
802
|
const delivery = new Delivery({
|
|
803
|
+
teams: teamPolicy,
|
|
804
|
+
// CON-04 inbound rule: only the user, app:concierge and replies to its asks reach a Concierge session.
|
|
805
|
+
inbound: conciergeInbound({ identity: conciergeTimedIdentity, audit: { record: (userID, entry) => audit.record(userID, entry) } }),
|
|
692
806
|
state: backends.state,
|
|
693
807
|
counters: backends.counters,
|
|
694
808
|
registry,
|
|
@@ -706,6 +820,8 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
706
820
|
},
|
|
707
821
|
// Push kind "message": a msg to @me, an ask left unanswered, a message loop (sent even while watched).
|
|
708
822
|
alert: (userID, alert) => pushUser("message", userID, alert.sessionID, `${alert.kind}:${alert.messageID}`),
|
|
823
|
+
// A message pending for 24 h expired undelivered: journaled (message.changed, delivery "expired") for its sender.
|
|
824
|
+
expired: (userID, message) => feed.publish(userID, message),
|
|
709
825
|
...(loopTickMs !== undefined ? { tickMs: loopTickMs } : {}),
|
|
710
826
|
// Cluster mode: the re-wake loop (leader) looks at every user; each pending flush holds a (user, session) lease.
|
|
711
827
|
...(config.cluster ? { users: allUsers, locks: backends.locks, owner: leaseOwner } : {}),
|
|
@@ -714,6 +830,8 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
714
830
|
const feed = new MessageFeed()
|
|
715
831
|
// Cluster mode: messages stored on any pod reach this pod's listeners (ask waiters, agent watches).
|
|
716
832
|
if (config.cluster) feed.attach({ bus: backends.bus, podID: config.cluster.podID }).catch((cause) => console.error(`[hub] message feed did not attach: ${(cause as Error).message}`))
|
|
833
|
+
/** (CON-06) The Concierge tiers (set below, once the Concierge modules exist): proposals' act, via, disarm. */
|
|
834
|
+
let conciergeTierHooks: ConciergeTiers | undefined
|
|
717
835
|
// Decisions (design §12.1): asks to @me; deadlines run over every user's Decisions (survive restarts).
|
|
718
836
|
const decisions = new Decisions({
|
|
719
837
|
state: backends.state,
|
|
@@ -722,10 +840,27 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
722
840
|
users: () => store.users().map((u) => u.id),
|
|
723
841
|
notify: (userID, decision, reason) => pushUser("decision", userID, decision.sessionID ?? "", `${decision.id}:${reason}`, decision.id),
|
|
724
842
|
// The console's live Decision cards: opened, answered (here, by timeout), escalated, expired.
|
|
725
|
-
changed: (userID, decision) =>
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
843
|
+
changed: (userID, decision) => {
|
|
844
|
+
broadcast({ type: "decision", decision }, { tenant: userID })
|
|
845
|
+
conciergeTierHooks?.decisionChanged?.(userID, decision)
|
|
846
|
+
},
|
|
847
|
+
// Escalations' Decisions reply to the permission as the user; grant requests' create the grant as the user;
|
|
848
|
+
// (CON-06) Concierge proposals run their verb as the user (all set up below).
|
|
849
|
+
act: (userID, decision, choice, by) => {
|
|
850
|
+
const kind = decision.action?.kind
|
|
851
|
+
if (kind === "act") {
|
|
852
|
+
if (!conciergeTierHooks?.act) throw new Error("This decision's action cannot run on this hub")
|
|
853
|
+
return conciergeTierHooks.act(userID, decision, choice, by)
|
|
854
|
+
}
|
|
855
|
+
return kind === "grant" ? grantRequests.act(userID, decision, choice, by) : escalations.act(userID, decision, choice, by)
|
|
856
|
+
},
|
|
857
|
+
// (CON-06) Voice/text answers: only what the Concierge tiers admit (armed, presence, the user's device).
|
|
858
|
+
via: async (userID, decision, via, by) => {
|
|
859
|
+
if (!conciergeTierHooks?.checkVia) throw new AgentError("forbidden", TAP_ONLY)
|
|
860
|
+
await conciergeTierHooks.checkVia(userID, decision, via, by)
|
|
861
|
+
},
|
|
862
|
+
// (FX-07) Time-limited and fail closed: a timed-out read refuses the ask with busy (no label silently dropped).
|
|
863
|
+
concierge: conciergeProtects(conciergeTimedIdentity),
|
|
729
864
|
...(loopTickMs !== undefined ? { tickMs: loopTickMs } : {}),
|
|
730
865
|
})
|
|
731
866
|
leader("decisions", { onElected: () => decisions.start(), onDeposed: () => decisions.stop() })
|
|
@@ -737,15 +872,26 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
737
872
|
}
|
|
738
873
|
registry.onSessions((connID) => void delivery.onSessions(connID).catch((cause) => console.error(`[hub] message flush failed: ${(cause as Error).message}`)))
|
|
739
874
|
registry.onHumanTurn((sessionID) => void humanTurn(sessionID))
|
|
740
|
-
|
|
741
|
-
const
|
|
875
|
+
// (CON-02) Session intents; the journal is created below (appends happen only once requests arrive).
|
|
876
|
+
const intents = new SessionIntents({
|
|
877
|
+
state: backends.state,
|
|
878
|
+
now,
|
|
879
|
+
events: { append: (userID, event) => agentEvents.append(userID, event) },
|
|
880
|
+
// The owner's consoles refetch the intent (the Details rail's card); the text never rides the stream.
|
|
881
|
+
onChange: (userID, sessionID, source, revision) => broadcast({ type: "intent", sessionID, source, revision }, { tenant: userID }),
|
|
882
|
+
})
|
|
883
|
+
/**
|
|
884
|
+
* Permission, form, prompt and stop execution, shared by the console routes and the agent act verbs. Every
|
|
885
|
+
* prompt the hub carries derives the session's intent once (CON-02).
|
|
886
|
+
*/
|
|
887
|
+
const actions = withPromptIntents(createActions({
|
|
742
888
|
registry,
|
|
743
889
|
spokes,
|
|
744
890
|
humanTurn,
|
|
745
891
|
kv: backends.kv,
|
|
746
892
|
// Cluster mode: a turn also holds the session's lease, so two pods never start turns in it at once.
|
|
747
893
|
...(config.cluster ? { turns: { locks: backends.locks, owner: leaseOwner, ttlMs: config.turnTimeoutMs + TURN_LEASE_SLACK_MS } } : {}),
|
|
748
|
-
})
|
|
894
|
+
}), intents, (sessionID) => registry.sessionTenant(sessionID), (sessionID) => registry.session(sessionID)?.title)
|
|
749
895
|
leader("delivery", { onElected: () => delivery.start(), onDeposed: () => delivery.stop() })
|
|
750
896
|
// Launches (design §6): placement onto the user's nodes, prepare on the node, the session through the machine's
|
|
751
897
|
// service spoke, then its end; restart recovery runs on start. Every change goes to launchFeed: the owner's
|
|
@@ -754,7 +900,10 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
754
900
|
// Cluster mode: launch changes made on any pod reach this pod's agent watches. The console broadcast, closing a
|
|
755
901
|
// launch's connections and webhooks run only on the publishing pod (origin): they reach every pod themselves.
|
|
756
902
|
if (config.cluster) launchFeed.attach({ bus: backends.bus, podID: config.cluster.podID }).catch((cause) => console.error(`[hub] launch feed did not attach: ${(cause as Error).message}`))
|
|
757
|
-
|
|
903
|
+
// The user's live view: flagged when the launch's app token was revoked (launches is set below, before any change).
|
|
904
|
+
launchFeed.subscribe((userID, launch) => broadcast({ type: "launch", launch: launchInfo(launches.live(launch)) }, { tenant: userID }), { origin: true })
|
|
905
|
+
// Profile application (HWA-15), created with the profile store below; a launch naming a profile applies it.
|
|
906
|
+
let profileApplier: ProfileApplier | undefined
|
|
758
907
|
const launches = new LaunchEngine({
|
|
759
908
|
state: backends.state,
|
|
760
909
|
counters: backends.counters,
|
|
@@ -775,6 +924,16 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
775
924
|
users: () => store.users().map((u) => u.id),
|
|
776
925
|
now,
|
|
777
926
|
onLaunchChanged: (launch) => launchFeed.publish(launch.owner, launch),
|
|
927
|
+
// FX-02: a waiting text change of the same kind: the console's live stream (and the Concierge's status) only,
|
|
928
|
+
// never the journal or webhooks.
|
|
929
|
+
onLaunchWaiting: (launch) => {
|
|
930
|
+
broadcast({ type: "launch", launch: launchInfo(launches.live(launch)) }, { tenant: launch.owner })
|
|
931
|
+
if (isConciergeLaunch(launch)) conciergeSupervisor.kick(launch.owner)
|
|
932
|
+
},
|
|
933
|
+
intents,
|
|
934
|
+
// FX-02: a launch of a revoked app token (`app:<16 hex>` no longer among the user's tokens) is flagged, not stopped.
|
|
935
|
+
launchers: { revoked: (userID, by) => /^app:[0-9a-f]{16}$/.test(by) && !(store.user(userID)?.agent?.appTokens ?? []).some((t) => `app:${t.id}` === by) },
|
|
936
|
+
profiles: { applyAtLaunch: async (userID, sessionID, ref) => (profileApplier ? profileApplier.applyAtLaunch(userID, sessionID, ref) : [`profile ${ref.profileID} v${ref.version} not applied: the hub is starting`]) },
|
|
778
937
|
// Cluster mode: the supervisor runs on the launches leader; other pods kick it and send it their stops.
|
|
779
938
|
...(config.cluster
|
|
780
939
|
? {
|
|
@@ -839,11 +998,19 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
839
998
|
})
|
|
840
999
|
// The canonical agent event journal (lifecycle design, Events): each change appended once across the cluster;
|
|
841
1000
|
// presence by the agent-events leader, everything else where it happens (event-writers.ts).
|
|
1001
|
+
let teamIntake: TeamIntakeService | undefined // HWA-36: drains queued intake on staffing/presence events (below)
|
|
1002
|
+
/** (CON-04) Journal appends for the Concierge (context refresh, CON-05's notifier); set once the service exists. */
|
|
1003
|
+
let conciergeOnAppend: ((userID: string, event: AgentEvent) => void) | undefined
|
|
1004
|
+
// CON-01: the Concierge watchlist's lastActiveAt per session, from appends (best effort, never awaited; observe never
|
|
1005
|
+
// rejects, and the catch keeps it that way: an unhandled rejection would end the process).
|
|
1006
|
+
const conciergeActivity = new ActivityTracker({ state: backends.state, now })
|
|
1007
|
+
// CON-03: the board's last result per root session, from appends (best effort, as above).
|
|
1008
|
+
const conciergeResults = new ResultTracker({ state: backends.state, now })
|
|
842
1009
|
const agentEvents = new AgentEvents({
|
|
843
1010
|
log: backends.agentEvents ?? new MemoryEventLog({ now, max: AGENT_EVENTS_MAX, maxAgeMs: AGENT_EVENTS_AGE_MS }),
|
|
844
1011
|
kv: backends.kv,
|
|
845
1012
|
now,
|
|
846
|
-
onAppend: (userID, record) => webhooks.publishCanonical(userID, record.event, record.family, record.audience).then((queued) => (queued && !webhookLeader.isLeader() ? webhookLeader.kick() : undefined)),
|
|
1013
|
+
onAppend: (userID, record) => (void teamIntake?.onEvent(userID, record.event), conciergeOnAppend?.(userID, record.event), void conciergeActivity.observe(userID, record.event).catch(() => {}), void conciergeResults.observe(userID, record.event).catch(() => {}), webhooks.publishCanonical(userID, record.event, record.family, record.audience).then((queued) => (queued && !webhookLeader.isLeader() ? webhookLeader.kick() : undefined))),
|
|
847
1014
|
})
|
|
848
1015
|
const agentEventWriters = new EventWriters({
|
|
849
1016
|
events: agentEvents,
|
|
@@ -856,13 +1023,58 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
856
1023
|
})
|
|
857
1024
|
agentEventWriters.start()
|
|
858
1025
|
leader("agent-events", { onElected: () => agentEventWriters.elected(), onDeposed: () => agentEventWriters.deposed() })
|
|
1026
|
+
// Persistent artifacts (HWA-21): metadata in the StateStore, bytes in Backends.blobs (files standalone, Postgres
|
|
1027
|
+
// in a cluster (HWA-22); a bundle without blobs answers unsupported). Events go to the journal (family artifact); recovery
|
|
1028
|
+
// and archive purging run on a leader.
|
|
1029
|
+
const artifacts = backends.blobs
|
|
1030
|
+
? new ArtifactStore({
|
|
1031
|
+
state: backends.state,
|
|
1032
|
+
blobs: backends.blobs,
|
|
1033
|
+
now,
|
|
1034
|
+
workspaces: artifactWorkspacesFromState(backends.state),
|
|
1035
|
+
onEvent: (userID, event) => void agentEvents.append(userID, artifactJournalEvent(event)).catch((cause) => console.error(`[hub] artifact event failed: ${(cause as Error).message}`)),
|
|
1036
|
+
})
|
|
1037
|
+
: undefined
|
|
1038
|
+
const artifactMaintenance = artifacts ? new ArtifactMaintenance({ artifacts, users: allUsers }) : undefined
|
|
1039
|
+
// Isolated artifact host (HWA-26): /artifact-host/v1/<capability>/… on this process (opaque-origin sandbox).
|
|
1040
|
+
const artifactHostCaps = new ArtifactHostCaps({ state: backends.state, keys: tokenKeys, now })
|
|
1041
|
+
const artifactHostServer = artifactHost({ caps: artifactHostCaps, ...(artifacts ? { artifacts } : {}), consoleOrigins: () => [...origins], deviceActive: (userID, id) => store.devices().some((d) => d.id === id && d.ownerID === userID), now })
|
|
1042
|
+
// (CON-02) Session intent retention (agent/intents.ts sweep), on one leader every 6 h: online sessions (the
|
|
1043
|
+
// registry, whatever the directory holds) are kept; records of deleted sessions and of sessions neither seen nor
|
|
1044
|
+
// written for 30 days are dropped. The directory (at most 2000 records per user) only adds lastSeen/deletedAt.
|
|
1045
|
+
const intentRetention = new IntentRetention({
|
|
1046
|
+
intents,
|
|
1047
|
+
users: allUsers,
|
|
1048
|
+
now,
|
|
1049
|
+
// (CON-10) Run Concierge prompt proposals: the build before CON-09a derived them as the session's intent.
|
|
1050
|
+
proposed: async (userID) =>
|
|
1051
|
+
(await decisions.list(userID, { state: "answered" })).flatMap((d) =>
|
|
1052
|
+
d.as === "concierge" && d.action?.kind === "act" && d.action.verb === "prompt" && d.answer?.option === "go" && typeof d.action.input.text === "string"
|
|
1053
|
+
? [{ sessionID: d.action.target, text: d.action.input.text, at: d.answer.at }]
|
|
1054
|
+
: [],
|
|
1055
|
+
),
|
|
1056
|
+
facts: (userID) => {
|
|
1057
|
+
const known = new Map((store.agentData(userID).sessions ?? []).map((s) => [s.sessionID, s]))
|
|
1058
|
+
return (sessionID) => {
|
|
1059
|
+
const s = known.get(sessionID)
|
|
1060
|
+
const lastSeen = s?.lastSeen ?? s?.firstSeen
|
|
1061
|
+
return {
|
|
1062
|
+
online: registry.sessionTenant(sessionID) === userID && registry.hasSession(sessionID),
|
|
1063
|
+
...(lastSeen !== undefined ? { lastSeen } : {}),
|
|
1064
|
+
...(s?.deletedAt !== undefined ? { deletedAt: s.deletedAt } : {}),
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
},
|
|
1068
|
+
})
|
|
1069
|
+
leader("session-intents", { onElected: () => intentRetention.start(), onDeposed: () => intentRetention.stop() })
|
|
1070
|
+
if (artifactMaintenance) leader("artifacts", { onElected: () => artifactMaintenance.start(), onDeposed: () => artifactMaintenance.stop() })
|
|
859
1071
|
for (const loop of leaders) void loop.start()
|
|
860
1072
|
|
|
861
1073
|
// One credential resolver for every /api request: device cookie, device token, session and app tokens.
|
|
862
1074
|
// Every stored agent message also goes to the user's console streams (live transcript rows).
|
|
863
1075
|
feed.subscribe((userID, message) => broadcast({ type: "message", message }, { tenant: userID }), { origin: true })
|
|
864
|
-
const audit = new AgentAudit({ state: backends.state, now, admin: store, recorded: (userID, entry) => broadcast({ type: "agent", entry }, { tenant: userID }) })
|
|
865
|
-
const grants = new Grants({ state: backends.state, counters: backends.counters, directory, store, admin: store, now })
|
|
1076
|
+
const audit = new AgentAudit({ state: backends.state, now, admin: store, recorded: (userID, entry) => broadcast({ type: "agent", entry }, { tenant: userID }), concierge: (userID, sessionID) => isConciergeSession(conciergeTimedIdentity, userID, sessionID) })
|
|
1077
|
+
const grants = new Grants({ state: backends.state, counters: backends.counters, directory, store, admin: store, now, concierge: async (owner) => (await conciergeIdentity.current(owner)).hiddenSessionIDs })
|
|
866
1078
|
const acting = new ActingSwitch({ store, never: config.agentsNever, audit })
|
|
867
1079
|
/** An agent as envelopes name it (a session by its directory name). */
|
|
868
1080
|
const describeActor = (userID: string, actor: AgentActor) => {
|
|
@@ -909,7 +1121,269 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
909
1121
|
void escalations.closed(tenant, requestID).catch((cause) => console.error(`[hub] escalation close failed: ${(cause as Error).message}`))
|
|
910
1122
|
})
|
|
911
1123
|
const escalate: Escalate = (escalation) => escalations.escalate(escalation)
|
|
912
|
-
const
|
|
1124
|
+
const profiles: ProfileStore = new ProfileStore({ state: backends.state, now, ack: (userID, assignment, version) => profileApplier!.ackFor(userID, assignment, version) })
|
|
1125
|
+
profileApplier = new ProfileApplier({ profiles, registry, spokes, events: agentEvents, now })
|
|
1126
|
+
profileApplier.start()
|
|
1127
|
+
const workspaceStore = new WorkspaceStore({ state: backends.state, now, ownsMachine: (userID, machineID) => ownMachine(userID, machineID) !== undefined })
|
|
1128
|
+
const workspaceMembers = new MembershipStore({ state: backends.state, workspaces: workspaceStore, now, presence: directoryPresence(directory, (userID) => store.agentData(userID).sessions ?? []) })
|
|
1129
|
+
const workspaceSupervision = new SupervisionStore({ state: backends.state, workspaces: workspaceStore, members: workspaceMembers, now })
|
|
1130
|
+
const workspaceLifecycle = new WorkspaceLifecycle({ workspaces: workspaceStore, members: workspaceMembers, supervision: workspaceSupervision, events: agentEvents })
|
|
1131
|
+
registry.onSessionDeleted((sessionID, tenant) => void workspaceLifecycle.sessionEnded(tenant, sessionID).catch((cause) => console.error(`[hub] workspace session end failed: ${(cause as Error).message}`)))
|
|
1132
|
+
const workspaces = { workspaces: workspaceStore, members: workspaceMembers, supervision: workspaceSupervision, lifecycle: workspaceLifecycle }
|
|
1133
|
+
// Team staffing (HWA-35): seats end on session deletion; launched members are staffed when their launch runs.
|
|
1134
|
+
const sessionDirectory = (userID: string, sessionID: string) => (registry.sessionTenant(sessionID) === userID ? registry.session(sessionID)?.directory : undefined)
|
|
1135
|
+
const teamStaffing = new TeamStaffingService({ state: backends.state, teams: teamStore, seats: teamSeats, workspaces: workspaceStore, members: workspaceMembers, supervision: workspaceSupervision, presence: teamPresence, sessionDirectory, launches, grants, acting, audit, profiles, events: agentEvents, now, protects: conciergeProtects(conciergeTimedIdentity) })
|
|
1136
|
+
registry.onSessionDeleted((sessionID, tenant) => void teamStaffing.sessionEnded(tenant, sessionID).catch((cause) => console.error(`[hub] team session end failed: ${(cause as Error).message}`)))
|
|
1137
|
+
launchFeed.subscribe((userID, launch) => void teamStaffing.launchChanged(userID, launch).catch((cause) => console.error(`[hub] team staffing launch failed: ${(cause as Error).message}`)), { origin: true })
|
|
1138
|
+
// Team intake (HWA-36): delivered to an entry seat from the fixed party app:team-intake (never the submitter).
|
|
1139
|
+
teamIntake = new TeamIntakeService({ state: backends.state, teams: teamStore, seats: teamSeats, staffing: teamStaffing, members: workspaceMembers, presence: teamPresence, events: agentEvents, now, deliver: async (userID, sessionID, text, options = {}) => ({ messageID: (await delivery.send(userID, intakeActor(userID), sessionID, "msg", text, options.hop === undefined ? {} : { chainHop: options.hop })).message.id }), hopOf: (userID, sessionID) => delivery.hops.next(userID, { kind: "session", sessionID }) })
|
|
1140
|
+
// Supervisor checkpoints and batched digests (HWA-11): acknowledgments flush before answering; digests reach
|
|
1141
|
+
// supervisor sessions through Delivery (as coordinatorActor) from the workspace-coordinator leader.
|
|
1142
|
+
const workspaceCursors = journalCursors(tokenKeys, agentEvents)
|
|
1143
|
+
let workspaceCoordinator: SupervisorCoordinator | undefined
|
|
1144
|
+
const workspaceCheckpoints = new CheckpointStore({ ...workspaces, state: backends.state, cursors: workspaceCursors, now, onAck: async (u, w, m, b) => workspaceCoordinator?.acknowledged(u, w, m, b) })
|
|
1145
|
+
const coordinatorLeader = leader("workspace-coordinator", { onElected: () => workspaceCoordinator?.start(), onDeposed: () => workspaceCoordinator?.stop() })
|
|
1146
|
+
workspaceCoordinator = new SupervisorCoordinator({
|
|
1147
|
+
...workspaces,
|
|
1148
|
+
state: backends.state,
|
|
1149
|
+
events: agentEvents,
|
|
1150
|
+
cursors: workspaceCursors,
|
|
1151
|
+
checkpoints: workspaceCheckpoints,
|
|
1152
|
+
sessions: registryBoardSessions(directory, registry),
|
|
1153
|
+
send: async (userID, sessionID, text, options) => {
|
|
1154
|
+
const sent = await delivery.send(userID, coordinatorActor(userID), sessionID, "msg", text, options)
|
|
1155
|
+
return { messageID: sent.message.id, ...(sent.delivery ? { delivery: sent.delivery } : {}), ...(sent.woke !== undefined ? { woke: sent.woke } : {}) }
|
|
1156
|
+
},
|
|
1157
|
+
users: allUsers,
|
|
1158
|
+
isLeader: () => coordinatorLeader.isLeader(),
|
|
1159
|
+
owner: leaseOwner,
|
|
1160
|
+
now,
|
|
1161
|
+
...(loopTickMs !== undefined ? { tickMs: loopTickMs } : {}),
|
|
1162
|
+
})
|
|
1163
|
+
void coordinatorLeader.start() // created after the other leaders were started
|
|
1164
|
+
const teamDiscovery = new TeamDiscovery({ teams: teamStore, seats: teamSeats, directory, harnesses: catalogHarnesses(registry) })
|
|
1165
|
+
|
|
1166
|
+
// ---- CON-04 the Concierge: settings, launch, supervise, hide, context ----
|
|
1167
|
+
const conciergeSettings = new SettingsStore(backends.state, now)
|
|
1168
|
+
const conciergeNotes = new NotesStore(backends.state, now)
|
|
1169
|
+
const conciergeLabels = async (userID: string) => {
|
|
1170
|
+
const out = new Map<string, Readonly<Record<string, string>>>()
|
|
1171
|
+
for (const launch of await launches.list(userID)) if (launch.sessionID !== undefined) out.set(launch.sessionID, launch.spec.labels)
|
|
1172
|
+
return out
|
|
1173
|
+
}
|
|
1174
|
+
// The board's `armed` flag reads the tiers slot (built below).
|
|
1175
|
+
const conciergeArmed = async (userID: string) => conciergeTierHooks?.armedID?.(userID)
|
|
1176
|
+
// (CON-13) The board's inGrant: the tiers' acting engine (made below) checks whether the current Concierge's grant
|
|
1177
|
+
// would approve a pending request once, without acting.
|
|
1178
|
+
let conciergeActing: Acting | undefined
|
|
1179
|
+
const conciergeCoverage = async (userID: string, conciergeSessionID: string, sessionID: string, requestID: string) =>
|
|
1180
|
+
conciergeActing ? conciergeActing.coverage({ userID, actor: { kind: "session", userID, machineID: "", sessionID: conciergeSessionID, via: "act" } }, sessionID, requestID) : { inGrant: false, auto: false }
|
|
1181
|
+
const conciergeBoardDeps = { coverage: conciergeCoverage, identity: conciergeIdentity, state: backends.state, activity: conciergeActivity, directory, store, registry, decisions, labels: conciergeLabels, intents, results: conciergeResults, armed: conciergeArmed, timeZone: async (userID: string) => (await conciergeSettings.get(userID)).timeZone, now }
|
|
1182
|
+
const conciergeRevisions = new BoardRevisions(backends.state)
|
|
1183
|
+
const conciergeContext = new ContextPusher({
|
|
1184
|
+
registry,
|
|
1185
|
+
send: (connID, req) => spokes.request(connID, req),
|
|
1186
|
+
board: async (userID) => (await conciergeBoard(conciergeBoardDeps, conciergeRevisions, userID, (await conciergeIdentity.current(userID)).hiddenSessionIDs)).rendered,
|
|
1187
|
+
notes: conciergeNotes,
|
|
1188
|
+
settings: conciergeSettings,
|
|
1189
|
+
grant: async (userID, grantID) => {
|
|
1190
|
+
try {
|
|
1191
|
+
const grant = await grants.get(userID, grantID)
|
|
1192
|
+
return { ...grant, state: grantState(grant, now()) }
|
|
1193
|
+
} catch {
|
|
1194
|
+
return undefined
|
|
1195
|
+
}
|
|
1196
|
+
},
|
|
1197
|
+
// (CON-09a) Voice and voice confirm are in the instructions only when speech is set up for the user.
|
|
1198
|
+
voice: (userID) => options.speech !== undefined || !speechSettings.view(speechScope(userID)).ready.reason,
|
|
1199
|
+
now,
|
|
1200
|
+
})
|
|
1201
|
+
/** The user's machines as the Concierge's eligibility sees them (store, nodes, registry). */
|
|
1202
|
+
const conciergeMachines = (userID: string): MachineFacts[] => {
|
|
1203
|
+
const online = new Set(registry.catalog().map((m) => m.id))
|
|
1204
|
+
return store
|
|
1205
|
+
.machines()
|
|
1206
|
+
.filter((m) => !m.revoked && m.ownerID === userID)
|
|
1207
|
+
.map((m) => {
|
|
1208
|
+
const node = nodes.forMachine(m.id)
|
|
1209
|
+
const own = node && node.ownerID === userID ? node : undefined
|
|
1210
|
+
const conn = registry.serviceFor(m.id)
|
|
1211
|
+
return {
|
|
1212
|
+
id: m.id,
|
|
1213
|
+
name: m.name,
|
|
1214
|
+
online: online.has(m.id) || own !== undefined,
|
|
1215
|
+
...(own ? { node: { runtimes: own.runtimes, workspaceRoots: own.workspaceRoots, draining: own.draining } } : {}),
|
|
1216
|
+
...(conn !== undefined ? { service: { launch: registry.supports(conn, "launch"), sessionContext: registry.supports(conn, "sessionContext") } } : {}),
|
|
1217
|
+
}
|
|
1218
|
+
})
|
|
1219
|
+
}
|
|
1220
|
+
let conciergeService: ConciergeService | undefined
|
|
1221
|
+
let conciergeModulesNow: ReturnType<typeof conciergeModules> | undefined
|
|
1222
|
+
const conciergeSupervisor = new Supervisor({
|
|
1223
|
+
state: backends.state,
|
|
1224
|
+
settings: conciergeSettings,
|
|
1225
|
+
launches,
|
|
1226
|
+
directory,
|
|
1227
|
+
grants,
|
|
1228
|
+
eligibility: (userID, machineID) => conciergeService!.eligibility(userID, machineID),
|
|
1229
|
+
entitlements: C1_ENTITLEMENTS,
|
|
1230
|
+
context: { push: (userID, sessionID) => conciergeContext.push(userID, sessionID, { force: true }).then(() => undefined), clear: (userID, sessionID) => conciergeContext.clear(userID, sessionID), retire: async (userID, sessionID) => {
|
|
1231
|
+
// (CON-10) Hub updates still pending for it are dropped: never delivered to (or shown waiting for) an ended session.
|
|
1232
|
+
const dropped = await delivery.dropPending(userID, sessionID).catch((cause) => (console.error(`[hub] concierge pending drop failed: ${(cause as Error).message}`), 0))
|
|
1233
|
+
// Audited as a count only (never the updates' text).
|
|
1234
|
+
if (dropped > 0) await store.audit({ actor: userID, action: "concierge.updates_dropped", target: sessionID, detail: String(dropped) }).catch(() => undefined)
|
|
1235
|
+
await conciergeContext.retire(userID, sessionID)
|
|
1236
|
+
} },
|
|
1237
|
+
onStatus: (userID, status) => {
|
|
1238
|
+
broadcast({ type: "concierge", status }, { tenant: userID })
|
|
1239
|
+
void agentEvents.append(userID, conciergeEvent({ what: "status", state: status.state, generation: status.generation })).catch((cause) => console.error(`[hub] concierge event failed: ${(cause as Error).message}`))
|
|
1240
|
+
if (status.state === "disabled") void conciergeModulesNow?.notifier.disabled(userID).catch(() => undefined)
|
|
1241
|
+
},
|
|
1242
|
+
audit: async (userID, action) => void (await store.audit({ actor: userID, action })),
|
|
1243
|
+
// A session that becomes the Concierge leaves every workspace (member, master) and team seat it held: hub-owned.
|
|
1244
|
+
onAdopted: async (userID, sessionID) => {
|
|
1245
|
+
const [members, seats] = await Promise.all([workspaceLifecycle.sessionEnded(userID, sessionID), teamStaffing.sessionEnded(userID, sessionID)])
|
|
1246
|
+
if (members.length || seats.length) await store.audit({ actor: userID, action: "concierge.detached", target: sessionID, detail: `${members.length} workspace memberships, ${seats.length} team seats` })
|
|
1247
|
+
},
|
|
1248
|
+
onSuspended: (userID, sessionID) => void notifier.notify("concierge", sessionID, "Concierge", undefined, userID, { key: `suspended:${sessionID}` }).catch(() => 0),
|
|
1249
|
+
users: allUsers,
|
|
1250
|
+
now,
|
|
1251
|
+
})
|
|
1252
|
+
conciergeService = new ConciergeService({
|
|
1253
|
+
settings: conciergeSettings,
|
|
1254
|
+
notes: conciergeNotes,
|
|
1255
|
+
supervisor: conciergeSupervisor,
|
|
1256
|
+
grants,
|
|
1257
|
+
machines: conciergeMachines,
|
|
1258
|
+
entitlements: C1_ENTITLEMENTS,
|
|
1259
|
+
audit: async (userID, action) => void (await store.audit({ actor: userID, action })),
|
|
1260
|
+
// The file store buffers writes (FILE_STATE_DEBOUNCE_MS); Postgres writes through.
|
|
1261
|
+
...(backends.state.flush ? { flush: () => backends.state.flush!() } : {}),
|
|
1262
|
+
})
|
|
1263
|
+
const conciergePorts: ConciergePorts = {
|
|
1264
|
+
identity: conciergeIdentity,
|
|
1265
|
+
status: (userID) => conciergeSupervisor.status(userID),
|
|
1266
|
+
settings: (userID) => conciergeSettings.get(userID),
|
|
1267
|
+
beforeWake: async (userID) => {
|
|
1268
|
+
const { sessionID } = await conciergeIdentity.current(userID)
|
|
1269
|
+
return sessionID !== undefined ? conciergeContext.beforeWake(userID, sessionID) : false
|
|
1270
|
+
},
|
|
1271
|
+
send: async (userID, text, sendOptions) => {
|
|
1272
|
+
const { sessionID } = await conciergeIdentity.current(userID)
|
|
1273
|
+
if (sessionID === undefined) throw new AgentError("not_found", "The Concierge is not running")
|
|
1274
|
+
const sent = await delivery.send(userID, conciergeActor(userID), sessionID, "msg", text, { wake: sendOptions.wake, ...(sendOptions.urgent ? { urgent: true } : {}) })
|
|
1275
|
+
return { messageID: sent.message.id, ...(sent.woke !== undefined ? { woke: sent.woke } : {}) }
|
|
1276
|
+
},
|
|
1277
|
+
entitlements: C1_ENTITLEMENTS,
|
|
1278
|
+
users: allUsers,
|
|
1279
|
+
// CON-05 the notifier's journal (cursor), ledger store, watchlist view, board revision and the concierge lease.
|
|
1280
|
+
journal: {
|
|
1281
|
+
events: agentEvents,
|
|
1282
|
+
state: backends.state,
|
|
1283
|
+
watched: (userID, hidden) => watchedSessions(conciergeBoardDeps, userID, hidden),
|
|
1284
|
+
boardRevision: async (userID, hidden) => (await conciergeBoard(conciergeBoardDeps, conciergeRevisions, userID, hidden)).board.revision,
|
|
1285
|
+
decisionOf: async (userID, messageID) => {
|
|
1286
|
+
const decision = await decisions.byMessage(userID, messageID)
|
|
1287
|
+
return decision ? { id: decision.id, about: decision.about } : undefined
|
|
1288
|
+
},
|
|
1289
|
+
isLeader: () => conciergeLeader.isLeader(),
|
|
1290
|
+
owner: leaseOwner,
|
|
1291
|
+
now,
|
|
1292
|
+
...(loopTickMs !== undefined ? { tickMs: loopTickMs } : {}),
|
|
1293
|
+
},
|
|
1294
|
+
// CON-06: proposals run through the same acting engine as the act verbs (agent/routes.ts actVerbRoutes).
|
|
1295
|
+
tiers: {
|
|
1296
|
+
state: backends.state,
|
|
1297
|
+
counters: backends.counters,
|
|
1298
|
+
decisions,
|
|
1299
|
+
acting: (conciergeActing = new Acting({ registry, directory, actions, spokes: { request: (connID, req) => spokes.request(connID, req) }, grants, acting, audit, escalate, maxPromptChars: MAX_PROMPT_CHARS, protect: conciergeProtects(conciergeTimedIdentity), conciergeActor: conciergeProtects(conciergeTimedIdentity) })),
|
|
1300
|
+
audit,
|
|
1301
|
+
protects: conciergeProtects(conciergeIdentity),
|
|
1302
|
+
published: (userID, message) => feed.publish(userID, message),
|
|
1303
|
+
describe: describeActor,
|
|
1304
|
+
present: (deviceID, sessionID) => registry.present(deviceID, sessionID),
|
|
1305
|
+
deviceOwner: (deviceID) => store.devices().find((d) => d.id === deviceID)?.ownerID,
|
|
1306
|
+
// The board shows the armed Decision: refresh the Concierge's context too.
|
|
1307
|
+
armed: (userID, armed) => (broadcast({ type: "concierge", armed }, { tenant: userID }), conciergeTouch(userID)),
|
|
1308
|
+
now,
|
|
1309
|
+
},
|
|
1310
|
+
}
|
|
1311
|
+
const conciergeParts = conciergeModules(conciergePorts)
|
|
1312
|
+
conciergeModulesNow = conciergeParts
|
|
1313
|
+
conciergeTierHooks = conciergeParts.tiers
|
|
1314
|
+
/** The user's current Concierge session, for a debounced context refresh. */
|
|
1315
|
+
const conciergeTouch = (userID: string, soon = false) => conciergeContext.touch(userID, async () => (await conciergeIdentity.current(userID)).sessionID, soon ? { soon } : {})
|
|
1316
|
+
conciergeOnAppend = (userID, event) => {
|
|
1317
|
+
try {
|
|
1318
|
+
// (CON-13) A blocker opened or closed reaches the Concierge's board within a second.
|
|
1319
|
+
conciergeTouch(userID, event.type === "blocker.opened" || event.type === "blocker.closed")
|
|
1320
|
+
conciergeParts.notifier.observe(userID, event)
|
|
1321
|
+
} catch (cause) {
|
|
1322
|
+
console.error(`[hub] concierge append hook failed: ${(cause as Error).message}`)
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
// Kicks: a Concierge launch changed, a node connected, a spoke reported its sessions (also re-sends the context
|
|
1326
|
+
// to a restarted spoke: a new connection reports the session).
|
|
1327
|
+
launchFeed.subscribe((userID, launch) => (isConciergeLaunch(launch) ? conciergeSupervisor.kick(userID) : undefined), { origin: true })
|
|
1328
|
+
nodes.subscribe((event) => (event.type === "connected" && event.node.ownerID !== undefined ? conciergeSupervisor.kick(event.node.ownerID) : undefined), { remote: true })
|
|
1329
|
+
/**
|
|
1330
|
+
* What the spoke reports about the user's current Concierge session (each sessions frame): on another agent than
|
|
1331
|
+
* `concierge` → suspended at once (grant revoked, authority gone; only the user restarts it); without the
|
|
1332
|
+
* context revision the hub sent (a restarted or reconnected spoke) → the context is re-sent now, not debounced.
|
|
1333
|
+
*/
|
|
1334
|
+
const conciergeForced = new Map<string, number>()
|
|
1335
|
+
const conciergeReported = async (tenant: string) => {
|
|
1336
|
+
const { sessionID, hiddenSessionIDs } = await conciergeIdentity.current(tenant)
|
|
1337
|
+
// (CON-09a) A previous Concierge session a spoke reports without the retired context's revision (a restarted
|
|
1338
|
+
// spoke, or one that lost it) is retired again (locked, powerless): forced, at most once per CONCIERGE_FORCED_PUSH_MS.
|
|
1339
|
+
for (const id of hiddenSessionIDs) {
|
|
1340
|
+
if (id === sessionID || registry.sessionTenant(id) !== tenant) continue
|
|
1341
|
+
const view = registry.session(id)
|
|
1342
|
+
if (!view || view.contextRevision === conciergeContext.revisionOf(tenant, id)) continue
|
|
1343
|
+
// A reported revision that is not ours: the spoke lost it, so the same connection gets it again (forced).
|
|
1344
|
+
// None reported (a new connection, an older spoke): sent once per connection (not forced).
|
|
1345
|
+
const force = view.contextRevision !== undefined
|
|
1346
|
+
const key = `retire\n${tenant}\n${id}`
|
|
1347
|
+
const at = now()
|
|
1348
|
+
if (force && at - (conciergeForced.get(key) ?? -Infinity) < CONCIERGE_FORCED_PUSH_MS) continue
|
|
1349
|
+
// Re-read: a relaunch may have adopted this very session meanwhile (never retire the current one).
|
|
1350
|
+
if ((await conciergeIdentity.current(tenant)).sessionID === id) continue
|
|
1351
|
+
if (force) conciergeForced.set(key, at)
|
|
1352
|
+
await conciergeContext.retire(tenant, id, { force })
|
|
1353
|
+
}
|
|
1354
|
+
if (sessionID === undefined || registry.sessionTenant(sessionID) !== tenant) return
|
|
1355
|
+
const view = registry.session(sessionID)
|
|
1356
|
+
if (!view) return
|
|
1357
|
+
// OpenCode's internal agents (title, compaction, summary) are not tamper; every other agent is.
|
|
1358
|
+
if (view.agent !== undefined && view.agent !== CONCIERGE_AGENT && !INTERNAL_AGENTS.has(view.agent)) {
|
|
1359
|
+
if (!KNOWN_PRIMARY_AGENTS.has(view.agent)) console.warn(`[hub] concierge session ${sessionID} reported on unknown agent ${view.agent.slice(0, 60)}: suspending`)
|
|
1360
|
+
await conciergeSupervisor.suspend(tenant, sessionID, CONCIERGE_TAMPERED)
|
|
1361
|
+
await backends.state.flush?.()
|
|
1362
|
+
return
|
|
1363
|
+
}
|
|
1364
|
+
const revision = conciergeContext.revisionOf(tenant, sessionID)
|
|
1365
|
+
if (revision !== undefined && view.contextRevision === revision) return
|
|
1366
|
+
// At most one forced push per session every CONCIERGE_FORCED_PUSH_MS (the debounced refresh still runs).
|
|
1367
|
+
const key = `${tenant}\n${sessionID}`
|
|
1368
|
+
const at = now()
|
|
1369
|
+
if (at - (conciergeForced.get(key) ?? -Infinity) < CONCIERGE_FORCED_PUSH_MS) return
|
|
1370
|
+
conciergeForced.set(key, at)
|
|
1371
|
+
if (conciergeForced.size > 1000) conciergeForced.delete(conciergeForced.keys().next().value as string)
|
|
1372
|
+
await conciergeContext.push(tenant, sessionID, { force: true })
|
|
1373
|
+
}
|
|
1374
|
+
registry.onSessions((connID) => {
|
|
1375
|
+
const tenant = registry.connTenant(connID)
|
|
1376
|
+
if (tenant === undefined) return
|
|
1377
|
+
conciergeSupervisor.kick(tenant)
|
|
1378
|
+
conciergeTouch(tenant)
|
|
1379
|
+
void conciergeReported(tenant).catch((cause) => console.error(`[hub] concierge report check failed: ${(cause as Error).message}`))
|
|
1380
|
+
})
|
|
1381
|
+
const conciergeLeader = leader("concierge", {
|
|
1382
|
+
onElected: () => (conciergeSupervisor.start(), conciergeParts.notifier.start()),
|
|
1383
|
+
onDeposed: () => (conciergeSupervisor.stop(), conciergeParts.notifier.stop()),
|
|
1384
|
+
})
|
|
1385
|
+
void conciergeLeader.start()
|
|
1386
|
+
const agentDeps = { intents, teamDiscovery, draining: drainSignal.signal, store, sessionTokens, directory, registry, spokes, delivery, decisions, feed, heartbeatMs, now, audit, grants, grantRequests, acting, actions, escalate, launches, webhooks, agentEvents, eventCursorKeys: tokenKeys, maxPromptChars: MAX_PROMPT_CHARS, profiles, workspaces, workspaceCheckpoints, teamIntake, graph: { teams: { teams: teamStore, seats: teamSeats! } }, teams: { teams: teamStore, seats: teamSeats!, staffing: teamStaffing }, artifacts: { ...(artifacts && backends.blobs ? { artifacts, blobs: backends.blobs } : {}), state: backends.state, now }, artifactHost: { caps: artifactHostCaps, ...(artifacts ? { artifacts } : {}) }, concierge: { state: backends.state, activity: conciergeActivity, results: conciergeResults, identity: conciergeTimedIdentity, protects: conciergeProtects(conciergeTimedIdentity), service: conciergeService, models: machineModels({ serviceFor: (machineID) => registry.serviceFor(machineID), reportedSessions: (connID) => registry.reportedSessions(connID), request: (connID, req) => spokes.request(connID, req) }), routes: () => conciergeParts.tiers.routes(), armed: conciergeArmed, timeZone: conciergeBoardDeps.timeZone, coverage: conciergeCoverage } }
|
|
913
1387
|
const resolveActor = createResolver({
|
|
914
1388
|
cookieName: COOKIE,
|
|
915
1389
|
findDevice: (token) => deviceOfToken(token),
|
|
@@ -926,6 +1400,8 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
926
1400
|
void store.touchUser(user.id)
|
|
927
1401
|
},
|
|
928
1402
|
routes: [...agentRoutes(agentDeps), ...(options.agent?.routes ?? [])],
|
|
1403
|
+
// CON-04: a Concierge session's tokens reach only an explicit route allow-list (fail closed).
|
|
1404
|
+
gate: conciergeSessionGate(conciergeTimedIdentity, () => conciergeParts.tiers.routes()),
|
|
929
1405
|
})
|
|
930
1406
|
|
|
931
1407
|
// POST /mcp (MCP Streamable HTTP): `hub mcp`'s tools as the bearer's caller; their /api/agent calls are dispatched
|
|
@@ -943,11 +1419,12 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
943
1419
|
const { pathname } = url
|
|
944
1420
|
const method = request.method
|
|
945
1421
|
try {
|
|
946
|
-
|
|
947
|
-
if (
|
|
948
|
-
|
|
949
|
-
return
|
|
1422
|
+
// HEAD (uptime monitors, `curl -I`) gets GET's status and headers without a body, on the health routes only.
|
|
1423
|
+
if ((method === "GET" || method === "HEAD") && (pathname === "/readyz" || pathname === "/healthz")) {
|
|
1424
|
+
const response = pathname === "/readyz" ? await readyz() : healthz()
|
|
1425
|
+
return method === "HEAD" ? new Response(null, { status: response.status, headers: response.headers }) : response
|
|
950
1426
|
}
|
|
1427
|
+
if (pathname === "/spoke/ws") return spokes.upgrade(request, server)
|
|
951
1428
|
// A shared (cluster) store loads its records on first use; the file store is ready at once.
|
|
952
1429
|
await store.ready()
|
|
953
1430
|
if (method === "POST" && pathname === "/spoke/enroll") {
|
|
@@ -987,6 +1464,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
987
1464
|
if (method === "GET" && pathname === "/login") return login(request, url)
|
|
988
1465
|
if (oidc && method === "GET" && pathname === "/auth/login") return await authLogin(url)
|
|
989
1466
|
if (oidc && method === "GET" && pathname === "/auth/callback") return await authCallback(request, url)
|
|
1467
|
+
if (isArtifactHostPath(pathname)) return await artifactHostServer.handle(request, url)
|
|
990
1468
|
if (pathname === MCP_PATH) return await mcp(request)
|
|
991
1469
|
if (isAgentPath(pathname)) return await agent.handle(request, url)
|
|
992
1470
|
if (pathname.startsWith("/api/")) return await api(request, server, url)
|
|
@@ -1093,19 +1571,24 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1093
1571
|
),
|
|
1094
1572
|
route("GET", "/api/machines", "user", ({ user }) => Response.json({ machines: machines(user.id) })),
|
|
1095
1573
|
route("GET", "/api/events", "user", ({ request, server, user, device, url }) => {
|
|
1574
|
+
const shown = streamSessionsQuery(url)
|
|
1575
|
+
if ("problem" in shown) return error(400, shown.problem)
|
|
1096
1576
|
server.timeout(request, 0)
|
|
1097
|
-
|
|
1577
|
+
// Only the user's own sessions; others are left out silently (no hint whether they exist).
|
|
1578
|
+
const sessions = shown.sessions.filter((session) => registry.owns(user.id, session))
|
|
1098
1579
|
return events(request.signal, user.id, device.id, {
|
|
1099
1580
|
lastID: request.headers.get("last-event-id") ?? url.searchParams.get("last") ?? undefined,
|
|
1100
1581
|
lite: url.searchParams.get("lite") === "1",
|
|
1101
|
-
...(
|
|
1582
|
+
...(sessions.length ? { sessions } : {}),
|
|
1583
|
+
// A pane console learns what is streamed (an older console, with ?session= only, gets no new message).
|
|
1584
|
+
scope: shown.multi,
|
|
1102
1585
|
})
|
|
1103
1586
|
}),
|
|
1104
1587
|
// The phone's session view (hidden/pinned sessions and workspaces), per user, synced to their other devices.
|
|
1105
1588
|
route("GET", "/api/prefs/view", "user", ({ user }) => Response.json({ prefs: store.viewPrefs(user.id) }, { headers: NO_STORE })),
|
|
1106
1589
|
route("PUT", "/api/prefs/view", "user", async ({ request, user, device }) => {
|
|
1107
1590
|
const before = new Set(store.viewPrefs(user.id).hiddenSessions)
|
|
1108
|
-
const prefs = await store.setViewPrefs(user.id, viewPrefsInput(await request
|
|
1591
|
+
const prefs = await store.setViewPrefs(user.id, viewPrefsInput(await jsonBody(request)))
|
|
1109
1592
|
// The user's other devices only: never another user, and not the device that made the change.
|
|
1110
1593
|
toUser(user.id, { type: "prefs", prefs }, device.id)
|
|
1111
1594
|
// Archive changes made in the console go to the agent event journal like the agent API's.
|
|
@@ -1220,9 +1703,12 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1220
1703
|
// The caller's own settings (pairing mode: the single owner's are the server default).
|
|
1221
1704
|
route("GET", "/api/settings/speech", "user", ({ user }) => Response.json(speechSettings.view(speechScope(user.id)), { headers: NO_STORE })),
|
|
1222
1705
|
// Raw JSON: anything but an object is refused by the validator.
|
|
1223
|
-
route("PUT", "/api/settings/speech", "user", async ({ request, user }) =>
|
|
1224
|
-
|
|
1225
|
-
|
|
1706
|
+
route("PUT", "/api/settings/speech", "user", async ({ request, user }) => {
|
|
1707
|
+
const view = await speechSettings.update(await jsonBody(request), speechScope(user.id))
|
|
1708
|
+
// (CON-10) The Concierge's instructions mention voice only while speech is ready: refresh its context.
|
|
1709
|
+
conciergeTouch(user.id)
|
|
1710
|
+
return Response.json(view, { headers: NO_STORE })
|
|
1711
|
+
}),
|
|
1226
1712
|
route("GET", "/api/settings/speech/models", "user", async ({ url, user }) => {
|
|
1227
1713
|
const kind = url.searchParams.get("kind")
|
|
1228
1714
|
if (kind !== "stt" && kind !== "tts") return error(400, "Expected kind=stt or kind=tts")
|
|
@@ -1267,6 +1753,16 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1267
1753
|
return Response.json(stopped.result, { headers: NO_STORE })
|
|
1268
1754
|
}),
|
|
1269
1755
|
route("POST", "/api/sessions/:session/rename", "session", ({ request, params }) => rename(request, params.session!)),
|
|
1756
|
+
// Fork and revert (feature "fork"/"revert"; console only, audited): see docs/session-view.md.
|
|
1757
|
+
route("POST", "/api/sessions/:session/fork", "session", ({ request, params, user }) => forkSession(request, params.session!, user.id)),
|
|
1758
|
+
route("GET", "/api/sessions/:session/revert/preview", "session", async ({ params, url }) => {
|
|
1759
|
+
const messageID = messageIDInput(url.searchParams.get("messageID"))
|
|
1760
|
+
const preview = await spokes.request(featureOwner(params.session!, "revert"), { op: "revertPreview", sessionID: params.session!, messageID })
|
|
1761
|
+
return Response.json(preview, { headers: NO_STORE })
|
|
1762
|
+
}),
|
|
1763
|
+
route("POST", "/api/sessions/:session/revert", "session", ({ request, params, user }) => revertSession(request, params.session!, user.id)),
|
|
1764
|
+
route("POST", "/api/sessions/:session/revert/restore", "session", ({ params, user }) => revertEnd(params.session!, user.id, "revertRestore")),
|
|
1765
|
+
route("POST", "/api/sessions/:session/revert/commit", "session", ({ params, user }) => revertEnd(params.session!, user.id, "revertCommit")),
|
|
1270
1766
|
route("POST", "/api/sessions/:session/conversation", "session", ({ request, params }) => conversation(request, params.session!), true),
|
|
1271
1767
|
route("GET", "/api/sessions/:session/models", "session", async ({ params }) =>
|
|
1272
1768
|
Response.json(await spokes.request(ownerOf(params.session!), { op: "models", sessionID: params.session! }), { headers: NO_STORE })),
|
|
@@ -1281,7 +1777,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1281
1777
|
return Response.json({ prefs, defaults: defaults.defaults }, { headers: NO_STORE })
|
|
1282
1778
|
}),
|
|
1283
1779
|
route("PUT", "/api/sessions/:session/model-prefs", "session", async ({ request, params, user }) => {
|
|
1284
|
-
const change = modelPrefsInput(await request
|
|
1780
|
+
const change = modelPrefsInput(await jsonBody(request))
|
|
1285
1781
|
await checkModelSelections(ownerOf(params.session!), params.session!, change)
|
|
1286
1782
|
const reply = await spokes.request(ownerOf(params.session!), { op: "setModelPrefs", sessionID: params.session!, ...change })
|
|
1287
1783
|
const prefs = await store.setModelPrefs(user.id, params.session!, change)
|
|
@@ -1332,8 +1828,8 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1332
1828
|
"session",
|
|
1333
1829
|
async ({ params }) => Response.json(await spokes.request(ownerOf(params.session!), { op: "context", sessionID: params.session! })),
|
|
1334
1830
|
),
|
|
1335
|
-
route("POST", "/api/sessions/:session/permission", "session", ({ request, params }) => permission(request, params.session
|
|
1336
|
-
route("POST", "/api/sessions/:session/form", "session", ({ request, params }) => form(request, params.session
|
|
1831
|
+
route("POST", "/api/sessions/:session/permission", "session", ({ request, params, user }) => permission(request, params.session!, user.id)),
|
|
1832
|
+
route("POST", "/api/sessions/:session/form", "session", ({ request, params, user }) => form(request, params.session!, user.id)),
|
|
1337
1833
|
route("GET", "/api/sessions/:session/summary", "session", async ({ params }) =>
|
|
1338
1834
|
Response.json(await spokes.request(ownerOf(params.session!), { op: "summary", sessionID: params.session! }), { headers: NO_STORE })),
|
|
1339
1835
|
route("GET", "/api/sessions/:session/diff", "session", async ({ params, url }) => {
|
|
@@ -1402,6 +1898,8 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1402
1898
|
const decision = await decisions.get(user.id, params.id!)
|
|
1403
1899
|
return decision ? Response.json({ decision }, { headers: NO_STORE }) : error(404, "No such decision", "not_found")
|
|
1404
1900
|
}),
|
|
1901
|
+
// CON-06: `via: "voice" | "text"` (the Concierge panel) only for an armed confirm Decision on a device present on
|
|
1902
|
+
// the current Concierge session (tiers.ts); else 403 "Answer this decision with a tap".
|
|
1405
1903
|
route("POST", "/api/decisions/:id/answer", "user", async ({ request, params, user, device }) => {
|
|
1406
1904
|
const input = await body(request)
|
|
1407
1905
|
const via = (await resolveActor.mode(request)) === "bearer" ? "device-token" : "cookie"
|
|
@@ -1416,7 +1914,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1416
1914
|
}),
|
|
1417
1915
|
route("GET", "/api/settings/speech/default", "admin", () => Response.json(speechSettings.view(), { headers: NO_STORE })),
|
|
1418
1916
|
route("PUT", "/api/settings/speech/default", "admin", async ({ request, user }) => {
|
|
1419
|
-
const view = await speechSettings.update(await request
|
|
1917
|
+
const view = await speechSettings.update(await jsonBody(request))
|
|
1420
1918
|
await store.audit({ actor: user.id, action: "speech.default" })
|
|
1421
1919
|
return Response.json(view, { headers: NO_STORE })
|
|
1422
1920
|
}),
|
|
@@ -1646,6 +2144,64 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1646
2144
|
return connID
|
|
1647
2145
|
}
|
|
1648
2146
|
|
|
2147
|
+
/**
|
|
2148
|
+
* The process that runs a fork or revert op: the session's owner, else another process reporting it, that
|
|
2149
|
+
* announced the feature; 409 unsupported {feature, harness} when none did (another harness, an older plugin, or
|
|
2150
|
+
* an OpenCode process that cannot reach its own server).
|
|
2151
|
+
*/
|
|
2152
|
+
function featureOwner(sessionID: string, feature: "fork" | "revert"): string {
|
|
2153
|
+
const targets = registry.sessionTargets(sessionID)
|
|
2154
|
+
if (!targets.length) throw new HttpError(409, NO_OWNER)
|
|
2155
|
+
const connID = targets.find((c) => registry.supports(c, feature))
|
|
2156
|
+
if (!connID) throw unsupportedError({ feature, harness: registry.harnessOf(targets[0]!) ?? "unknown" })
|
|
2157
|
+
return connID
|
|
2158
|
+
}
|
|
2159
|
+
|
|
2160
|
+
/** A user message ID from a request (1–300 ID characters), else 400. */
|
|
2161
|
+
function messageIDInput(raw: unknown): string {
|
|
2162
|
+
if (typeof raw !== "string" || !MESSAGE_ID.test(raw)) throw new HttpError(400, "Expected {messageID}: one of the session's user messages")
|
|
2163
|
+
return raw
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2166
|
+
/** A spoke's refusal of a revert while the session runs, as 409 busy; anything else unchanged. */
|
|
2167
|
+
function busyRefusal(cause: unknown): unknown {
|
|
2168
|
+
return cause instanceof SpokeError && cause.message === REVERT_BUSY ? new HttpError(409, REVERT_BUSY, "busy") : cause
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
/** POST …/fork {messageID}: a new session with the conversation before that user message (201). */
|
|
2172
|
+
async function forkSession(request: Request, sessionID: string, userID: string): Promise<Response> {
|
|
2173
|
+
const messageID = messageIDInput((await body(request)).messageID)
|
|
2174
|
+
const connID = featureOwner(sessionID, "fork")
|
|
2175
|
+
const result = await spokes.request(connID, { op: "fork", sessionID, messageID })
|
|
2176
|
+
await store.audit({ actor: userID, action: "session.fork", target: sessionID, detail: `${messageID} -> ${result.sessionID}` })
|
|
2177
|
+
return Response.json(result, { status: 201, headers: NO_STORE })
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
/** POST …/revert {messageID, files?}: stages a revert (files restored unless files: false); 409 while it runs. */
|
|
2181
|
+
async function revertSession(request: Request, sessionID: string, userID: string): Promise<Response> {
|
|
2182
|
+
const input = await body(request)
|
|
2183
|
+
const messageID = messageIDInput(input.messageID)
|
|
2184
|
+
if (input.files !== undefined && typeof input.files !== "boolean") return error(400, "files must be true or false")
|
|
2185
|
+
const files = input.files !== false
|
|
2186
|
+
const connID = featureOwner(sessionID, "revert")
|
|
2187
|
+
if (registry.running(sessionID)) throw new HttpError(409, REVERT_BUSY, "busy")
|
|
2188
|
+
const result = await spokes.request(connID, { op: "revert", sessionID, messageID, files }).catch((cause) => {
|
|
2189
|
+
throw busyRefusal(cause)
|
|
2190
|
+
})
|
|
2191
|
+
await store.audit({ actor: userID, action: "session.revert", target: sessionID, detail: files ? messageID : `${messageID} (messages only)` })
|
|
2192
|
+
return Response.json(result, { headers: NO_STORE })
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
/** POST …/revert/restore (undo) or …/revert/commit (make permanent): 204. */
|
|
2196
|
+
async function revertEnd(sessionID: string, userID: string, op: "revertRestore" | "revertCommit"): Promise<Response> {
|
|
2197
|
+
const connID = featureOwner(sessionID, "revert")
|
|
2198
|
+
await spokes.request(connID, { op, sessionID }).catch((cause) => {
|
|
2199
|
+
throw busyRefusal(cause)
|
|
2200
|
+
})
|
|
2201
|
+
await store.audit({ actor: userID, action: op === "revertRestore" ? "session.revert.restore" : "session.revert.commit", target: sessionID })
|
|
2202
|
+
return new Response(null, { status: 204 })
|
|
2203
|
+
}
|
|
2204
|
+
|
|
1649
2205
|
/** Conversation mode: steer the utterance into the session (allowed while it is busy) and return at once. */
|
|
1650
2206
|
async function utterance(request: Request, sessionID: string): Promise<Response> {
|
|
1651
2207
|
const raw = (await body(request)).text
|
|
@@ -1766,17 +2322,27 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1766
2322
|
return Response.json({ sessionID: result.sessionID, ...(openedIn ? { openedIn } : {}), ...(warning ? { warning } : {}) }, { status: 201 })
|
|
1767
2323
|
}
|
|
1768
2324
|
|
|
1769
|
-
|
|
2325
|
+
/**
|
|
2326
|
+
* (CON-13) The user's own answer on a session's card, in the agent audit as actor user (as the acting engine records
|
|
2327
|
+
* a user's act through /api/agent): Settings → Agents → Activity then shows "you denied …" next to the agents'
|
|
2328
|
+
* answers. The asking session (a subagent's request is answered through its root) is the target. Best effort.
|
|
2329
|
+
*/
|
|
2330
|
+
const auditUserAnswer = (userID: string, verb: "approve" | "deny" | "answer", target: { sessionID: string; requestID?: string; formID?: string }) =>
|
|
2331
|
+
void audit.record(userID, { actor: { kind: "user" }, verb, target, decision: "allow", outcome: "ok" }).catch((cause: unknown) => console.error(`[hub] audit of a console answer failed: ${(cause as Error).message}`))
|
|
2332
|
+
|
|
2333
|
+
async function permission(request: Request, rootID: string, userID: string): Promise<Response> {
|
|
1770
2334
|
const input = await body(request)
|
|
1771
2335
|
const decision = input.decision as PermissionDecision
|
|
1772
2336
|
if (typeof input.requestID !== "string" || !DECISIONS.has(decision)) return error(400, "Invalid decision")
|
|
2337
|
+
const asking = registry.permissionRequest(input.requestID)?.sessionID ?? rootID
|
|
1773
2338
|
const answered = await actions.answerPermission({ rootID, requestID: input.requestID, decision })
|
|
1774
2339
|
if (answered.outcome === "unsupported") throw unsupportedError(answered)
|
|
2340
|
+
if (answered.outcome === "done") auditUserAnswer(userID, decision === "reject" ? "deny" : "approve", { sessionID: asking, requestID: input.requestID })
|
|
1775
2341
|
return answered.outcome === "done" ? new Response(null, { status: 204 }) : error(409, PERMISSION_GONE, "gone")
|
|
1776
2342
|
}
|
|
1777
2343
|
|
|
1778
2344
|
/** Answers or cancels a pending form (question) in the session's tree, through a process that holds it. */
|
|
1779
|
-
async function form(request: Request, rootID: string): Promise<Response> {
|
|
2345
|
+
async function form(request: Request, rootID: string, userID: string): Promise<Response> {
|
|
1780
2346
|
const input = await body(request)
|
|
1781
2347
|
if (typeof input.formID !== "string" || !input.formID || input.formID.length > 300) return error(400, "Expected {formID, answer} or {formID, cancel: true}")
|
|
1782
2348
|
const cancel = input.cancel === true
|
|
@@ -1785,10 +2351,12 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1785
2351
|
const problem = formAnswerProblem(input.answer)
|
|
1786
2352
|
if (problem) return error(400, problem)
|
|
1787
2353
|
}
|
|
2354
|
+
const asking = registry.status(rootID)?.find((s) => s.blockers.some((b) => b.kind === "form" && b.id === input.formID))?.id ?? rootID
|
|
1788
2355
|
const answered = await actions.answerForm(
|
|
1789
2356
|
cancel ? { rootID, formID: input.formID, cancel: true } : { rootID, formID: input.formID, answer: input.answer as FormAnswer },
|
|
1790
2357
|
)
|
|
1791
2358
|
if (answered.outcome === "unsupported") throw unsupportedError(answered)
|
|
2359
|
+
if (answered.outcome === "done") auditUserAnswer(userID, "answer", { sessionID: asking, formID: input.formID })
|
|
1792
2360
|
return answered.outcome === "done" ? new Response(null, { status: 204 }) : error(409, PERMISSION_GONE, "gone")
|
|
1793
2361
|
}
|
|
1794
2362
|
|
|
@@ -1798,8 +2366,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1798
2366
|
if (Number(request.headers.get("content-length") ?? 0) > config.maxAudioBytes) {
|
|
1799
2367
|
return error(413, "Recording too large")
|
|
1800
2368
|
}
|
|
1801
|
-
const audio =
|
|
1802
|
-
if (audio.length > config.maxAudioBytes) return error(413, "Recording too large")
|
|
2369
|
+
const audio = await boundedBytes(request, config.maxAudioBytes, "Recording too large")
|
|
1803
2370
|
if (audio.length === 0) return error(400, "Empty recording")
|
|
1804
2371
|
return Response.json({ text: await speech.transcribe(audio, format) })
|
|
1805
2372
|
}
|
|
@@ -1809,7 +2376,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1809
2376
|
* after it when its log still covers the gap (one fresh catalog if catalogs changed meanwhile), else it starts
|
|
1810
2377
|
* over: the catalog, then a `resync` marker so the page refetches what it shows. Heartbeats are `ping` events.
|
|
1811
2378
|
*/
|
|
1812
|
-
function events(signal: AbortSignal, userID: string, deviceID: string, options: { lastID?: string; lite: boolean;
|
|
2379
|
+
function events(signal: AbortSignal, userID: string, deviceID: string, options: { lastID?: string; lite: boolean; sessions?: readonly string[]; scope?: boolean }): Response {
|
|
1813
2380
|
// Draining: come back (to another pod) shortly.
|
|
1814
2381
|
if (draining) return new Response(SSE_RETRY, { headers: { "content-type": "text/event-stream", "cache-control": "no-cache" } })
|
|
1815
2382
|
const encoder = new TextEncoder()
|
|
@@ -1827,7 +2394,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1827
2394
|
userID,
|
|
1828
2395
|
deviceID,
|
|
1829
2396
|
lite: options.lite,
|
|
1830
|
-
...(options.
|
|
2397
|
+
...(options.sessions ? { sessions: options.sessions } : {}),
|
|
1831
2398
|
send: (message, id) => write(`${id ? `id: ${id}\n` : ""}data: ${JSON.stringify(message)}\n\n`),
|
|
1832
2399
|
close: () => {
|
|
1833
2400
|
cleanup()
|
|
@@ -1840,6 +2407,12 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1840
2407
|
listener.close()
|
|
1841
2408
|
},
|
|
1842
2409
|
}
|
|
2410
|
+
// At once, whatever follows: Bun sends the response head with the first body chunk, and a resumed stream
|
|
2411
|
+
// with nothing to replay would otherwise stay silent until the first heartbeat, so the page's EventSource
|
|
2412
|
+
// would not open (its "Reconnecting…" showing) for up to heartbeatMs (RP-10). A comment: the page ignores it.
|
|
2413
|
+
write(": open\n\n")
|
|
2414
|
+
// Before anything else, so the page can tell a hub that streams several sessions from one that does not.
|
|
2415
|
+
if (options.scope) listener.send({ type: "scope", sessions: options.sessions ?? [] })
|
|
1843
2416
|
if (live) return clusterStream(listener, write)
|
|
1844
2417
|
eventLog.open(userID)
|
|
1845
2418
|
const missed = options.lastID ? eventLog.since(userID, options.lastID) : undefined
|
|
@@ -1918,6 +2491,10 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1918
2491
|
}
|
|
1919
2492
|
|
|
1920
2493
|
/** /readyz (design §10): 503 while draining or (cluster mode) when the database or Valkey does not answer in time. */
|
|
2494
|
+
function healthz(): Response {
|
|
2495
|
+
return Response.json({ ok: true, version: options.version ?? "0.0.0", spokes: registry.connections(), nodes: nodes.count() })
|
|
2496
|
+
}
|
|
2497
|
+
|
|
1921
2498
|
async function readyz(): Promise<Response> {
|
|
1922
2499
|
if (draining) return Response.json({ ok: false, draining: true }, { status: 503, headers: NO_STORE })
|
|
1923
2500
|
if (config.cluster) {
|
|
@@ -2012,7 +2589,11 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
2012
2589
|
launchTokens,
|
|
2013
2590
|
webhooks,
|
|
2014
2591
|
agentEvents,
|
|
2592
|
+
directory,
|
|
2593
|
+
concierge: conciergeService,
|
|
2015
2594
|
agentEventWriters,
|
|
2595
|
+
intentRetention,
|
|
2596
|
+
teams: { store: teamStore, seats: teamSeats, staffing: teamStaffing, intake: teamIntake },
|
|
2016
2597
|
routes: [...routes.map(({ method, path, access }) => ({ method, path, access })), ...agent.routes.map(({ method, path }) => ({ method, path, access: "agent" as const }))],
|
|
2017
2598
|
drain(drainOptions: DrainOptions = {}) {
|
|
2018
2599
|
draining ??= runDrain(drainOptions)
|
|
@@ -2022,7 +2603,12 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
2022
2603
|
// Step down first (releasing the leases), so another pod takes the timed jobs over at once.
|
|
2023
2604
|
await Promise.allSettled([...leaders.map((loop) => loop.stop()), recommendations?.stop()])
|
|
2024
2605
|
launches.shutdown()
|
|
2606
|
+
conciergeSupervisor.stop()
|
|
2607
|
+
conciergeContext.stop()
|
|
2608
|
+
conciergeParts.tiers.stop?.()
|
|
2025
2609
|
webhooks.stop()
|
|
2610
|
+
artifactMaintenance?.stop()
|
|
2611
|
+
profileApplier?.stop()
|
|
2026
2612
|
agentEventWriters.stop()
|
|
2027
2613
|
tokenPush.stop()
|
|
2028
2614
|
delivery.stop()
|
|
@@ -2047,18 +2633,19 @@ const optionalState = (url: URL): { state?: Decision["state"] } => {
|
|
|
2047
2633
|
return state ? { state } : {}
|
|
2048
2634
|
}
|
|
2049
2635
|
|
|
2050
|
-
/** {option?, text?} of a Decision answer (types only; Decisions validates). */
|
|
2051
|
-
function decisionAnswer(input: Record<string, unknown>): { option?: string; text?: string } {
|
|
2636
|
+
/** {option?, text?, via?} of a Decision answer (types only; Decisions validates). */
|
|
2637
|
+
function decisionAnswer(input: Record<string, unknown>): { option?: string; text?: string; via?: AnswerVia } {
|
|
2052
2638
|
for (const key of ["option", "text"] as const) {
|
|
2053
2639
|
if (input[key] !== undefined && typeof input[key] !== "string") throw new AgentError("invalid", `${key} must be a string`)
|
|
2054
2640
|
}
|
|
2055
|
-
|
|
2641
|
+
if (input.via !== undefined && !(ANSWER_VIAS as readonly unknown[]).includes(input.via)) throw new AgentError("invalid", `via is one of ${ANSWER_VIAS.join(", ")}`)
|
|
2642
|
+
return { ...(typeof input.option === "string" ? { option: input.option } : {}), ...(typeof input.text === "string" ? { text: input.text } : {}), ...(input.via !== undefined ? { via: input.via as AnswerVia } : {}) }
|
|
2056
2643
|
}
|
|
2057
2644
|
|
|
2058
2645
|
/** Console envelope for a Decision failure: gone → 409 (the web app's toast), the rest by code. */
|
|
2059
2646
|
function decisionFailure(cause: unknown): Response {
|
|
2060
2647
|
if (!(cause instanceof AgentError)) throw cause
|
|
2061
|
-
const status: Partial<Record<string, number>> = { gone: 409, not_found: 404, forbidden: 403, invalid: 400, rate_limited: 429, paused: 423 }
|
|
2648
|
+
const status: Partial<Record<string, number>> = { gone: 409, not_found: 404, forbidden: 403, invalid: 400, rate_limited: 429, paused: 423, offline: 503, unsupported: 422 }
|
|
2062
2649
|
return Response.json({ error: cause.message, code: cause.code, ...(cause.options.hint ? { hint: cause.options.hint } : {}) }, { status: status[cause.code] ?? 500 })
|
|
2063
2650
|
}
|
|
2064
2651
|
|
|
@@ -2172,13 +2759,32 @@ function param(raw: string): string {
|
|
|
2172
2759
|
|
|
2173
2760
|
const sleep = (ms: number) => new Promise<undefined>((resolve) => setTimeout(() => resolve(undefined), ms))
|
|
2174
2761
|
|
|
2762
|
+
/**
|
|
2763
|
+
* Console and public JSON bodies: at most this many bytes (agent/body.ts bounds the bytes and time read). Generous:
|
|
2764
|
+
* a prompt may carry up to 4 images (MAX_PROMPT_IMAGES; the validator refuses more than MAX_PROMPT_IMAGES_BASE64).
|
|
2765
|
+
*/
|
|
2766
|
+
const CONSOLE_JSON_BODY_MAX = 4 << 20
|
|
2767
|
+
|
|
2768
|
+
/** The bytes of a console/public request body, or HttpError 413 (too large) / 408 (too slow). */
|
|
2769
|
+
async function boundedBytes(request: Request, max: number, tooLarge: string): Promise<Uint8Array> {
|
|
2770
|
+
const read = await readBounded(request, max)
|
|
2771
|
+
if (read.ok) return read.bytes
|
|
2772
|
+
if (read.reason === "timeout") throw new HttpError(408, slowMessage(), undefined, true)
|
|
2773
|
+
throw new HttpError(413, tooLarge, undefined, read.close)
|
|
2774
|
+
}
|
|
2775
|
+
|
|
2776
|
+
/** A console/public JSON body (a SyntaxError for invalid JSON, as request.json() threw). */
|
|
2777
|
+
async function jsonBody(request: Request): Promise<unknown> {
|
|
2778
|
+
return JSON.parse(Buffer.from(await boundedBytes(request, CONSOLE_JSON_BODY_MAX, `Request body too large (at most ${CONSOLE_JSON_BODY_MAX} bytes)`)).toString("utf8"))
|
|
2779
|
+
}
|
|
2780
|
+
|
|
2175
2781
|
async function body(request: Request): Promise<Record<string, unknown>> {
|
|
2176
|
-
const value =
|
|
2782
|
+
const value = await jsonBody(request)
|
|
2177
2783
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {}
|
|
2178
2784
|
}
|
|
2179
2785
|
|
|
2180
2786
|
function failure(cause: unknown): Response {
|
|
2181
|
-
if (cause instanceof HttpError) return error(cause.status, cause.message, cause.code)
|
|
2787
|
+
if (cause instanceof HttpError) return error(cause.status, cause.message, cause.code, cause.close ? { connection: "close" } : undefined)
|
|
2182
2788
|
if (cause instanceof BusyError) return error(409, cause.message)
|
|
2183
2789
|
if (cause instanceof RateLimitError) return error(429, cause.message)
|
|
2184
2790
|
if (cause instanceof ValidationError) return error(400, cause.message)
|