@huynle/hub 0.7.3 → 0.8.0
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 +229 -0
- package/Dockerfile +7 -2
- package/README.md +153 -24
- 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/profile.ts +173 -0
- package/packages/claude-code/src/sessions.ts +22 -4
- package/packages/cli/dist/hub-cli.js +6474 -3470
- 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/enrollment/enroll.ts +20 -8
- package/packages/cli/src/bootstrap/enrollment/policy.ts +125 -12
- package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
- package/packages/cli/src/cli.ts +95 -13
- package/packages/cli/src/format.ts +48 -10
- package/packages/cli/src/http.ts +1 -0
- package/packages/cli/src/intents.ts +23 -0
- package/packages/cli/src/login.ts +20 -6
- package/packages/cli/src/mcp.ts +380 -9
- package/packages/cli/src/messaging.ts +3 -1
- package/packages/cli/src/setup/commands.ts +3 -2
- package/packages/cli/src/setup/runtime.ts +3 -2
- package/packages/cli/src/team-intake-mcp.ts +158 -0
- package/packages/cli/src/teams-mcp.ts +403 -0
- package/packages/cli/src/watchlist.ts +135 -0
- package/packages/codex/package.json +1 -1
- 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 +96 -2
- package/packages/node/src/opencode-warm.ts +325 -0
- package/packages/node/src/run.ts +27 -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 +169 -6
- package/packages/protocol/src/launch.ts +22 -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 +3 -1
- package/packages/sdk/src/index.ts +5 -0
- package/packages/sdk/src/team-intake.ts +122 -0
- package/packages/sdk/src/teams.ts +346 -0
- package/packages/sdk/src/types.ts +548 -1
- 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 +215 -10
- package/src/central/agent/app-tokens.ts +3 -2
- package/src/central/agent/audit.ts +17 -0
- package/src/central/agent/body.ts +163 -0
- package/src/central/agent/decisions.ts +69 -2
- package/src/central/agent/delivery.ts +95 -15
- package/src/central/agent/directory-routes.ts +37 -7
- package/src/central/agent/directory.ts +70 -14
- package/src/central/agent/errors.ts +5 -0
- package/src/central/agent/events-routes.ts +21 -4
- package/src/central/agent/events.ts +11 -3
- 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 +16 -2
- package/src/central/agent/launch-routes.ts +6 -1
- package/src/central/agent/launch-spec.ts +14 -1
- package/src/central/agent/message-routes.ts +2 -1
- package/src/central/agent/messages.ts +27 -2
- package/src/central/agent/names.ts +12 -2
- package/src/central/agent/read-routes.ts +86 -19
- package/src/central/agent/routes.ts +176 -5
- package/src/central/agent/webhook-routes.ts +2 -1
- 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 +773 -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 +339 -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 +89 -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 +130 -0
- package/src/central/concierge/proposals.ts +262 -0
- package/src/central/concierge/push-remap.ts +49 -0
- package/src/central/concierge/routes.ts +62 -0
- package/src/central/concierge/schedule.ts +113 -0
- package/src/central/concierge/service.ts +243 -0
- package/src/central/concierge/session-routes.ts +69 -0
- package/src/central/concierge/settings.ts +217 -0
- package/src/central/concierge/supervisor.ts +740 -0
- package/src/central/concierge/tiers.ts +260 -0
- package/src/central/concierge/watchlist-routes.ts +277 -0
- package/src/central/concierge/watchlist.ts +273 -0
- package/src/central/config.ts +7 -1
- package/src/central/http.ts +633 -50
- 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 +43 -6
- 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 +772 -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 +888 -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 +97 -15
- package/src/spoke/concierge-agent.ts +33 -0
- package/src/spoke/deliver.ts +22 -4
- package/src/spoke/fork-revert.ts +142 -0
- package/src/spoke/hub-rpc.ts +41 -5
- package/src/spoke/hub-tool.ts +383 -18
- package/src/spoke/listing.ts +47 -4
- package/src/spoke/profile-apply.ts +298 -0
- package/src/spoke/session-context.ts +176 -0
- package/src/spoke/spoke.ts +319 -20
- 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-BS0gVkfu.js +1 -0
- package/web/dist/assets/ArtifactPane-PLYHNs6s.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
- package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +6 -0
- package/web/dist/assets/Library-CtxRqTjo.js +2 -0
- package/web/dist/assets/Library-DYsFj_5c.css +1 -0
- package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +1 -0
- package/web/dist/assets/TeamsPage-BwjsAAUC.js +1 -0
- package/web/dist/assets/Watchtower-C4swrWVs.js +2 -0
- package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
- package/web/dist/assets/index-DaH5GUdV.js +31 -0
- package/web/dist/assets/index-WWuKfWxL.css +1 -0
- package/web/dist/assets/live-BUofsQpz.css +1 -0
- package/web/dist/assets/live-YyTjE47g.js +5 -0
- package/web/dist/assets/pane-layout-CKC4I00k.js +1 -0
- package/web/dist/assets/plural-Bspvoqfq.js +1 -0
- package/web/dist/assets/renderer2d-CX5j3NcD.js +1 -0
- package/web/dist/assets/renderer3d-BXIVOvfi.js +5393 -0
- package/web/dist/assets/safe-area-DfPsOdWR.js +1 -0
- package/web/dist/assets/source-CvUHTc3O.js +1 -0
- package/web/dist/assets/ui-C9Igxzjm.js +1 -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,46 @@ 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 } from "./concierge/session-routes"
|
|
95
|
+
import { conciergeAwareNotifier, isConciergeSession } from "./concierge/push-remap"
|
|
96
|
+
import { ConciergeService, type MachineFacts } from "./concierge/service"
|
|
97
|
+
import { SettingsStore } from "./concierge/settings"
|
|
98
|
+
import { CONCIERGE_TAMPERED, isConciergeLaunch, recordIdentity, StatusStore, Supervisor } from "./concierge/supervisor"
|
|
99
|
+
import { CONCIERGE_AGENT } from "../../packages/protocol/src/concierge"
|
|
100
|
+
import { CONCIERGE_FORCED_PUSH_MS, INTERNAL_AGENTS, KNOWN_PRIMARY_AGENTS } from "./concierge/context"
|
|
101
|
+
import { grantState } from "./agent/grants"
|
|
102
|
+
import { catalogHarnesses, TeamDiscovery } from "./teams/discovery"
|
|
60
103
|
import { DRAIN_RETRY_AFTER_MS, DRAIN_TIMEOUT_MS, RequestTimeoutError, RequestTooLargeError, SpokeError, SpokeHub, type DrainOptions, type Notifier, type SpokeHubOptions, type SpokeSocketData } from "./spoke-ws"
|
|
61
104
|
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
105
|
|
|
@@ -143,10 +186,18 @@ export interface Central {
|
|
|
143
186
|
readonly webhooks: Webhooks
|
|
144
187
|
/** The canonical agent event journal (GET /api/agent/events, MCP events): compact events per user, 24 h / 10,000. */
|
|
145
188
|
readonly agentEvents: AgentEvents
|
|
189
|
+
/** The agent directory (names, roles, address resolution). */
|
|
190
|
+
readonly directory: Directory
|
|
191
|
+
/** (CON-04) The Concierge service (settings, supervisor, context). */
|
|
192
|
+
readonly concierge: ConciergeService
|
|
146
193
|
/** The journal's writers (state, blockers, presence, messages, launches); settled() waits for queued appends. */
|
|
147
194
|
readonly agentEventWriters: EventWriters
|
|
195
|
+
/** (CON-02) Session intent retention (its leader runs it every 6 h; tests run a pass with runOnce). */
|
|
196
|
+
readonly intentRetention: IntentRetention
|
|
148
197
|
/** The JSON API's route table (method, path, access), for audits and tests. */
|
|
149
198
|
readonly routes: readonly RouteInfo[]
|
|
199
|
+
/** Teams (HWA-33..37): definitions, seats (the delivery policy's) and staffing; HWA-37 routes reuse these. */
|
|
200
|
+
readonly teams: { readonly store: TeamStore; readonly seats: TeamSeatStore; readonly staffing: TeamStaffingService; readonly intake: TeamIntakeService }
|
|
150
201
|
/**
|
|
151
202
|
* Draining before a shutdown (design §10; SIGTERM): /readyz fails, new /spoke/ws upgrades are refused, spokes and
|
|
152
203
|
* nodes get bye{reason:"draining", retryAfterMs} (once their requests in flight are answered), the leader leases
|
|
@@ -164,8 +215,8 @@ interface Listener {
|
|
|
164
215
|
readonly deviceID: string
|
|
165
216
|
/** Leaves out streamed text and reasoning (except of `session`'s tree). */
|
|
166
217
|
readonly lite: boolean
|
|
167
|
-
/** The
|
|
168
|
-
readonly
|
|
218
|
+
/** The sessions the page shows (one, or one per visible pane): their trees' text and reasoning stream even when lite. */
|
|
219
|
+
readonly sessions?: readonly string[]
|
|
169
220
|
/** Sends a message; `id` is its event id (EventSource's lastEventId). */
|
|
170
221
|
send(message: Message, id?: string): void
|
|
171
222
|
close(): void
|
|
@@ -176,6 +227,23 @@ interface Listener {
|
|
|
176
227
|
/** Messages a lite stream leaves out (unless they are of the listener's session tree). */
|
|
177
228
|
const STREAMED = new Set(["delta", "reasoning"])
|
|
178
229
|
|
|
230
|
+
/**
|
|
231
|
+
* The sessions a console stream shows (HWA-07 panes): `session` (the focused one; all an older console sends)
|
|
232
|
+
* then `sessions` (comma-separated, one per visible pane), each once, blanks ignored. At most
|
|
233
|
+
* WORKSPACE_LIMITS.panes; an ID over 300 characters or too many sessions is a problem (400), never truncated.
|
|
234
|
+
*/
|
|
235
|
+
function streamSessionsQuery(url: URL): { sessions: string[]; multi: boolean } | { problem: string } {
|
|
236
|
+
const listed = url.searchParams.getAll("sessions").flatMap((v) => v.split(","))
|
|
237
|
+
const sessions: string[] = []
|
|
238
|
+
for (const id of [url.searchParams.get("session") ?? "", ...listed]) {
|
|
239
|
+
if (!id || sessions.includes(id)) continue
|
|
240
|
+
if (id.length > 300) return { problem: "a session ID is too long" }
|
|
241
|
+
sessions.push(id)
|
|
242
|
+
}
|
|
243
|
+
if (sessions.length > WORKSPACE_LIMITS.panes) return { problem: `a stream shows at most ${WORKSPACE_LIMITS.panes} sessions` }
|
|
244
|
+
return { sessions, multi: url.searchParams.has("sessions") }
|
|
245
|
+
}
|
|
246
|
+
|
|
179
247
|
/**
|
|
180
248
|
* Who may call a route: anyone, any signed-in user, admins, or the owner of the :session / :machine in the path;
|
|
181
249
|
* "agent": an /api/agent route (src/central/agent), checked per credential kind there.
|
|
@@ -229,6 +297,10 @@ const LAUNCH_STOP_TIMEOUT_MS = 180_000
|
|
|
229
297
|
*/
|
|
230
298
|
export const TURN_ACCEPT_MS = 1500
|
|
231
299
|
const NO_OWNER = "No OpenCode process for this session is connected"
|
|
300
|
+
/** The spoke's refusal of a revert while the session runs (src/spoke/fork-revert.ts REVERT_BUSY). */
|
|
301
|
+
const REVERT_BUSY = "The session is working; wait for it to finish (or stop it) before reverting"
|
|
302
|
+
/** A message ID in a fork or revert request (as the spoke protocol bounds it). */
|
|
303
|
+
const MESSAGE_ID = /^[A-Za-z0-9_.:@/+~-]{1,300}$/
|
|
232
304
|
const AUTO_NAME_OUTDATED = "Update the Hub plugin to use Auto-name"
|
|
233
305
|
const NOT_EXPOSED = "Voice is not enabled for this session"
|
|
234
306
|
/** A permission or question that is not pending any more: someone answered it elsewhere, or it expired. */
|
|
@@ -251,13 +323,15 @@ const WEB_NOT_BUILT = [
|
|
|
251
323
|
"This hub has no built web app (web/dist).",
|
|
252
324
|
"",
|
|
253
325
|
"To run a hub, install it from npm (`opencode plugin add @huynle/hub`, or `bunx --package @huynle/hub hub-server`)",
|
|
254
|
-
"or use a release binary (https://github.com/huynle/hub/releases), which ship the web app.",
|
|
326
|
+
"or use a release binary (https://github.com/huynle/hub-releases/releases), which ship the web app.",
|
|
255
327
|
"In a source checkout, run `npm install && npm run build:web` first.",
|
|
256
328
|
].join("\n")
|
|
257
329
|
/** Preview text for POST /api/settings/speech/test. */
|
|
258
330
|
const SPEECH_TEST_TEXT = "Hi! This is how I will sound when I read replies to you."
|
|
259
331
|
const SPEECH_TEST_MAX_CHARS = 300
|
|
260
|
-
|
|
332
|
+
// Primary-navigation destinations (web/src/app/nav.ts) and one sub-path level, plus legacy routes the console
|
|
333
|
+
// redirects (birds-eye → watchtower, nav.ts LEGACY_ROUTES); test/central-web.test.ts checks the registry.
|
|
334
|
+
const APP_ROUTES = /^\/(?:|pair|approve|new|settings(?:\/[a-z-]+)?|panes|concierge(?:\/[^/]+)?|s\/[^/]+|(?:artifacts|teams|watchtower|birds-eye|profiles)(?:\/[^/]+)?)$/
|
|
261
335
|
const STATIC_PATH = /^(?:\/[A-Za-z0-9_-][A-Za-z0-9._-]*)+$/
|
|
262
336
|
/** Where a one-time sign-in link may land: home, or a session (with Voice mode starting: `?voice=1`). */
|
|
263
337
|
const LOGIN_NEXT = /^\/(?:s\/[A-Za-z0-9_-]+(?:\?voice=1)?)?$/
|
|
@@ -302,16 +376,18 @@ class HttpError extends Error {
|
|
|
302
376
|
message: string,
|
|
303
377
|
/** Machine-readable reason, e.g. not_exposed. */
|
|
304
378
|
readonly code?: string,
|
|
379
|
+
/** Answer with Connection: close (a request body was left unread on the socket: agent/body.ts). */
|
|
380
|
+
readonly close?: boolean,
|
|
305
381
|
) {
|
|
306
382
|
super(message)
|
|
307
383
|
}
|
|
308
384
|
}
|
|
309
385
|
|
|
310
386
|
/** A core op the session's spoke does not serve (another harness's spoke): 409 code unsupported, like plugin_outdated. */
|
|
311
|
-
const unsupportedError = (lacking:
|
|
387
|
+
const unsupportedError = (lacking: { readonly feature: Unsupported["feature"] | "fork" | "revert"; readonly harness: string }) => new HttpError(409, unsupportedMessage(lacking), "unsupported")
|
|
312
388
|
|
|
313
|
-
const error = (status: number, message: string, code?: string) =>
|
|
314
|
-
Response.json({ error: message, ...(code ? { code } : {}) }, { status })
|
|
389
|
+
const error = (status: number, message: string, code?: string, headers?: Record<string, string>) =>
|
|
390
|
+
Response.json({ error: message, ...(code ? { code } : {}) }, { status, ...(headers ? { headers } : {}) })
|
|
315
391
|
|
|
316
392
|
export function startCentral(options: CentralOptions): Central {
|
|
317
393
|
const { config } = options
|
|
@@ -330,7 +406,12 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
330
406
|
const registry = new Registry(now, undefined, (machineID) => store.machines().find((m) => m.id === machineID)?.ownerID)
|
|
331
407
|
const nodes = new NodeRegistry((machineID) => store.machines().find((m) => m.id === machineID)?.ownerID, now)
|
|
332
408
|
const push = new Push({ store, subject: config.vapidSubject, sender: options.pushSender, kv: backends.kv })
|
|
333
|
-
|
|
409
|
+
// CON-04: the Concierge's state record is read early (the Concierge-only authority, hiding, the inbound rule and
|
|
410
|
+
// push remapping all follow it); the service itself is built below, once its parts exist.
|
|
411
|
+
const conciergeStatus = new StatusStore(backends.state, now)
|
|
412
|
+
const conciergeIdentity = recordIdentity(conciergeStatus)
|
|
413
|
+
// Pushes about the Concierge's hidden sessions become kind "concierge" (/concierge) or are dropped.
|
|
414
|
+
const notifier = conciergeAwareNotifier(options.notifier ?? push, () => conciergeIdentity)
|
|
334
415
|
/** SSE subscribers, one user each. */
|
|
335
416
|
const listeners = new Set<Listener>()
|
|
336
417
|
/** Recent messages per user, replayed to a stream that reconnects with Last-Event-ID. */
|
|
@@ -338,9 +419,10 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
338
419
|
/** Whether a listener wants a message (lite streams skip other sessions' streamed text). */
|
|
339
420
|
const wants = (listener: Listener, message: Message): boolean => {
|
|
340
421
|
if (!listener.lite || !STREAMED.has(message.type)) return true
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
422
|
+
return (listener.sessions ?? []).some((session) => {
|
|
423
|
+
const root = registry.status(session)?.[0]?.id ?? session
|
|
424
|
+
return message.sessionID === session || message.rootID === root || message.sessionID === root
|
|
425
|
+
})
|
|
344
426
|
}
|
|
345
427
|
/**
|
|
346
428
|
* Cluster mode (design §9): a user's events go to every pod over the Bus (user.<id>.live), logged in the shared
|
|
@@ -439,7 +521,11 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
439
521
|
hostname: config.host,
|
|
440
522
|
port: config.port,
|
|
441
523
|
idleTimeout: 255,
|
|
442
|
-
|
|
524
|
+
// Staged artifact blobs stream up to ARTIFACT_UPLOAD_LIMITS.blobBytes (the routes bound every body themselves).
|
|
525
|
+
// Plus headroom for the drain past a route's bound (BODY_LIMITS.drainBytes, overshot by up to one chunk): Bun ≥ 1.4
|
|
526
|
+
// enforces this limit on chunked bodies too, answering a bare 413 and closing the socket before the route can
|
|
527
|
+
// give its own 400/413. It stays a backstop; the routes' bounds decide.
|
|
528
|
+
maxRequestBodySize: Math.max(config.maxAudioBytes + 1024, ARTIFACT_UPLOAD_LIMITS.blobBytes + 65_536, 1 << 20) + 2 * BODY_LIMITS.drainBytes,
|
|
443
529
|
fetch: (request, server) => handle(request, server),
|
|
444
530
|
websocket: {
|
|
445
531
|
open: (ws) => spokes.websocket.open?.(ws),
|
|
@@ -536,7 +622,11 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
536
622
|
// Push goes to the session owner's devices only; an alert without an owner goes nowhere.
|
|
537
623
|
if (!alert.owner) return
|
|
538
624
|
// Activity updates are collected and throttled per device by the push layer.
|
|
539
|
-
if (alert.kind === "activity")
|
|
625
|
+
if (alert.kind === "activity") {
|
|
626
|
+
// The Concierge's own activity is never pushed (its session is hidden).
|
|
627
|
+
void isConciergeSession(conciergeIdentity, alert.owner, alert.sessionID).then((hidden) => (hidden ? undefined : push.activity(alert)))
|
|
628
|
+
return
|
|
629
|
+
}
|
|
540
630
|
const extra = {
|
|
541
631
|
...(alert.requestID ? { requestID: alert.requestID } : {}),
|
|
542
632
|
...(alert.subject ? { subject: alert.subject } : {}),
|
|
@@ -688,7 +778,25 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
688
778
|
}
|
|
689
779
|
// Agent messages (design §3): delivered through spokes that announce "deliver"; pending ones flush on every
|
|
690
780
|
// sessions frame; human turns (console turn/utterance, prompts typed in the harness) reset hop chains.
|
|
781
|
+
// Teams (HWA-33) and their communication policy in delivery (HWA-34): a denial is audited and journaled (IDs only).
|
|
782
|
+
let teamSeats: TeamSeatStore | undefined
|
|
783
|
+
const teamStore = new TeamStore({ state: backends.state, now, pinnedVersions: (userID, teamID) => teamSeats!.pinnedVersions(userID, teamID) })
|
|
784
|
+
const teamPresence = directoryPresence(directory, (userID) => store.agentData(userID).sessions ?? [])
|
|
785
|
+
teamSeats = new TeamSeatStore({ state: backends.state, teams: teamStore, now, knowsSession: async (userID, sessionID) => !["unknown", "deleted"].includes(await teamPresence(userID, sessionID)) })
|
|
786
|
+
const teamPolicy = new TeamPolicy({
|
|
787
|
+
teams: teamStore,
|
|
788
|
+
seats: teamSeats,
|
|
789
|
+
directory,
|
|
790
|
+
denied: async (userID, denial) => {
|
|
791
|
+
const target = denial.recipientID !== undefined ? { sessionID: denial.recipientID } : {}
|
|
792
|
+
const reason = `team policy: ${denial.reason} (${denial.kind})`
|
|
793
|
+
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))])
|
|
794
|
+
},
|
|
795
|
+
})
|
|
691
796
|
const delivery = new Delivery({
|
|
797
|
+
teams: teamPolicy,
|
|
798
|
+
// CON-04 inbound rule: only the user, app:concierge and replies to its asks reach a Concierge session.
|
|
799
|
+
inbound: conciergeInbound({ identity: conciergeIdentity, audit: { record: (userID, entry) => audit.record(userID, entry) } }),
|
|
692
800
|
state: backends.state,
|
|
693
801
|
counters: backends.counters,
|
|
694
802
|
registry,
|
|
@@ -714,6 +822,8 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
714
822
|
const feed = new MessageFeed()
|
|
715
823
|
// Cluster mode: messages stored on any pod reach this pod's listeners (ask waiters, agent watches).
|
|
716
824
|
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}`))
|
|
825
|
+
/** (CON-06) The Concierge tiers (set below, once the Concierge modules exist): proposals' act, via, disarm. */
|
|
826
|
+
let conciergeTierHooks: ConciergeTiers | undefined
|
|
717
827
|
// Decisions (design §12.1): asks to @me; deadlines run over every user's Decisions (survive restarts).
|
|
718
828
|
const decisions = new Decisions({
|
|
719
829
|
state: backends.state,
|
|
@@ -722,10 +832,26 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
722
832
|
users: () => store.users().map((u) => u.id),
|
|
723
833
|
notify: (userID, decision, reason) => pushUser("decision", userID, decision.sessionID ?? "", `${decision.id}:${reason}`, decision.id),
|
|
724
834
|
// The console's live Decision cards: opened, answered (here, by timeout), escalated, expired.
|
|
725
|
-
changed: (userID, decision) =>
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
835
|
+
changed: (userID, decision) => {
|
|
836
|
+
broadcast({ type: "decision", decision }, { tenant: userID })
|
|
837
|
+
conciergeTierHooks?.decisionChanged?.(userID, decision)
|
|
838
|
+
},
|
|
839
|
+
// Escalations' Decisions reply to the permission as the user; grant requests' create the grant as the user;
|
|
840
|
+
// (CON-06) Concierge proposals run their verb as the user (all set up below).
|
|
841
|
+
act: (userID, decision, choice, by) => {
|
|
842
|
+
const kind = decision.action?.kind
|
|
843
|
+
if (kind === "act") {
|
|
844
|
+
if (!conciergeTierHooks?.act) throw new Error("This decision's action cannot run on this hub")
|
|
845
|
+
return conciergeTierHooks.act(userID, decision, choice, by)
|
|
846
|
+
}
|
|
847
|
+
return kind === "grant" ? grantRequests.act(userID, decision, choice, by) : escalations.act(userID, decision, choice, by)
|
|
848
|
+
},
|
|
849
|
+
// (CON-06) Voice/text answers: only what the Concierge tiers admit (armed, presence, the user's device).
|
|
850
|
+
via: async (userID, decision, via, by) => {
|
|
851
|
+
if (!conciergeTierHooks?.checkVia) throw new AgentError("forbidden", TAP_ONLY)
|
|
852
|
+
await conciergeTierHooks.checkVia(userID, decision, via, by)
|
|
853
|
+
},
|
|
854
|
+
concierge: (userID, sessionID) => isConciergeSession(conciergeIdentity, userID, sessionID),
|
|
729
855
|
...(loopTickMs !== undefined ? { tickMs: loopTickMs } : {}),
|
|
730
856
|
})
|
|
731
857
|
leader("decisions", { onElected: () => decisions.start(), onDeposed: () => decisions.stop() })
|
|
@@ -737,15 +863,26 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
737
863
|
}
|
|
738
864
|
registry.onSessions((connID) => void delivery.onSessions(connID).catch((cause) => console.error(`[hub] message flush failed: ${(cause as Error).message}`)))
|
|
739
865
|
registry.onHumanTurn((sessionID) => void humanTurn(sessionID))
|
|
740
|
-
|
|
741
|
-
const
|
|
866
|
+
// (CON-02) Session intents; the journal is created below (appends happen only once requests arrive).
|
|
867
|
+
const intents = new SessionIntents({
|
|
868
|
+
state: backends.state,
|
|
869
|
+
now,
|
|
870
|
+
events: { append: (userID, event) => agentEvents.append(userID, event) },
|
|
871
|
+
// The owner's consoles refetch the intent (the Details rail's card); the text never rides the stream.
|
|
872
|
+
onChange: (userID, sessionID, source, revision) => broadcast({ type: "intent", sessionID, source, revision }, { tenant: userID }),
|
|
873
|
+
})
|
|
874
|
+
/**
|
|
875
|
+
* Permission, form, prompt and stop execution, shared by the console routes and the agent act verbs. Every
|
|
876
|
+
* prompt the hub carries derives the session's intent once (CON-02).
|
|
877
|
+
*/
|
|
878
|
+
const actions = withPromptIntents(createActions({
|
|
742
879
|
registry,
|
|
743
880
|
spokes,
|
|
744
881
|
humanTurn,
|
|
745
882
|
kv: backends.kv,
|
|
746
883
|
// Cluster mode: a turn also holds the session's lease, so two pods never start turns in it at once.
|
|
747
884
|
...(config.cluster ? { turns: { locks: backends.locks, owner: leaseOwner, ttlMs: config.turnTimeoutMs + TURN_LEASE_SLACK_MS } } : {}),
|
|
748
|
-
})
|
|
885
|
+
}), intents, (sessionID) => registry.sessionTenant(sessionID), (sessionID) => registry.session(sessionID)?.title)
|
|
749
886
|
leader("delivery", { onElected: () => delivery.start(), onDeposed: () => delivery.stop() })
|
|
750
887
|
// Launches (design §6): placement onto the user's nodes, prepare on the node, the session through the machine's
|
|
751
888
|
// service spoke, then its end; restart recovery runs on start. Every change goes to launchFeed: the owner's
|
|
@@ -755,6 +892,8 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
755
892
|
// launch's connections and webhooks run only on the publishing pod (origin): they reach every pod themselves.
|
|
756
893
|
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
894
|
launchFeed.subscribe((userID, launch) => broadcast({ type: "launch", launch: launchInfo(launch) }, { tenant: userID }), { origin: true })
|
|
895
|
+
// Profile application (HWA-15), created with the profile store below; a launch naming a profile applies it.
|
|
896
|
+
let profileApplier: ProfileApplier | undefined
|
|
758
897
|
const launches = new LaunchEngine({
|
|
759
898
|
state: backends.state,
|
|
760
899
|
counters: backends.counters,
|
|
@@ -775,6 +914,8 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
775
914
|
users: () => store.users().map((u) => u.id),
|
|
776
915
|
now,
|
|
777
916
|
onLaunchChanged: (launch) => launchFeed.publish(launch.owner, launch),
|
|
917
|
+
intents,
|
|
918
|
+
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
919
|
// Cluster mode: the supervisor runs on the launches leader; other pods kick it and send it their stops.
|
|
779
920
|
...(config.cluster
|
|
780
921
|
? {
|
|
@@ -839,11 +980,19 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
839
980
|
})
|
|
840
981
|
// The canonical agent event journal (lifecycle design, Events): each change appended once across the cluster;
|
|
841
982
|
// presence by the agent-events leader, everything else where it happens (event-writers.ts).
|
|
983
|
+
let teamIntake: TeamIntakeService | undefined // HWA-36: drains queued intake on staffing/presence events (below)
|
|
984
|
+
/** (CON-04) Journal appends for the Concierge (context refresh, CON-05's notifier); set once the service exists. */
|
|
985
|
+
let conciergeOnAppend: ((userID: string, event: AgentEvent) => void) | undefined
|
|
986
|
+
// CON-01: the Concierge watchlist's lastActiveAt per session, from appends (best effort, never awaited; observe never
|
|
987
|
+
// rejects, and the catch keeps it that way: an unhandled rejection would end the process).
|
|
988
|
+
const conciergeActivity = new ActivityTracker({ state: backends.state, now })
|
|
989
|
+
// CON-03: the board's last result per root session, from appends (best effort, as above).
|
|
990
|
+
const conciergeResults = new ResultTracker({ state: backends.state, now })
|
|
842
991
|
const agentEvents = new AgentEvents({
|
|
843
992
|
log: backends.agentEvents ?? new MemoryEventLog({ now, max: AGENT_EVENTS_MAX, maxAgeMs: AGENT_EVENTS_AGE_MS }),
|
|
844
993
|
kv: backends.kv,
|
|
845
994
|
now,
|
|
846
|
-
onAppend: (userID, record) => webhooks.publishCanonical(userID, record.event, record.family, record.audience).then((queued) => (queued && !webhookLeader.isLeader() ? webhookLeader.kick() : undefined)),
|
|
995
|
+
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
996
|
})
|
|
848
997
|
const agentEventWriters = new EventWriters({
|
|
849
998
|
events: agentEvents,
|
|
@@ -856,13 +1005,58 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
856
1005
|
})
|
|
857
1006
|
agentEventWriters.start()
|
|
858
1007
|
leader("agent-events", { onElected: () => agentEventWriters.elected(), onDeposed: () => agentEventWriters.deposed() })
|
|
1008
|
+
// Persistent artifacts (HWA-21): metadata in the StateStore, bytes in Backends.blobs (files standalone, Postgres
|
|
1009
|
+
// in a cluster (HWA-22); a bundle without blobs answers unsupported). Events go to the journal (family artifact); recovery
|
|
1010
|
+
// and archive purging run on a leader.
|
|
1011
|
+
const artifacts = backends.blobs
|
|
1012
|
+
? new ArtifactStore({
|
|
1013
|
+
state: backends.state,
|
|
1014
|
+
blobs: backends.blobs,
|
|
1015
|
+
now,
|
|
1016
|
+
workspaces: artifactWorkspacesFromState(backends.state),
|
|
1017
|
+
onEvent: (userID, event) => void agentEvents.append(userID, artifactJournalEvent(event)).catch((cause) => console.error(`[hub] artifact event failed: ${(cause as Error).message}`)),
|
|
1018
|
+
})
|
|
1019
|
+
: undefined
|
|
1020
|
+
const artifactMaintenance = artifacts ? new ArtifactMaintenance({ artifacts, users: allUsers }) : undefined
|
|
1021
|
+
// Isolated artifact host (HWA-26): /artifact-host/v1/<capability>/… on this process (opaque-origin sandbox).
|
|
1022
|
+
const artifactHostCaps = new ArtifactHostCaps({ state: backends.state, keys: tokenKeys, now })
|
|
1023
|
+
const artifactHostServer = artifactHost({ caps: artifactHostCaps, ...(artifacts ? { artifacts } : {}), consoleOrigins: () => [...origins], deviceActive: (userID, id) => store.devices().some((d) => d.id === id && d.ownerID === userID), now })
|
|
1024
|
+
// (CON-02) Session intent retention (agent/intents.ts sweep), on one leader every 6 h: online sessions (the
|
|
1025
|
+
// registry, whatever the directory holds) are kept; records of deleted sessions and of sessions neither seen nor
|
|
1026
|
+
// written for 30 days are dropped. The directory (at most 2000 records per user) only adds lastSeen/deletedAt.
|
|
1027
|
+
const intentRetention = new IntentRetention({
|
|
1028
|
+
intents,
|
|
1029
|
+
users: allUsers,
|
|
1030
|
+
now,
|
|
1031
|
+
// (CON-10) Run Concierge prompt proposals: the build before CON-09a derived them as the session's intent.
|
|
1032
|
+
proposed: async (userID) =>
|
|
1033
|
+
(await decisions.list(userID, { state: "answered" })).flatMap((d) =>
|
|
1034
|
+
d.as === "concierge" && d.action?.kind === "act" && d.action.verb === "prompt" && d.answer?.option === "go" && typeof d.action.input.text === "string"
|
|
1035
|
+
? [{ sessionID: d.action.target, text: d.action.input.text, at: d.answer.at }]
|
|
1036
|
+
: [],
|
|
1037
|
+
),
|
|
1038
|
+
facts: (userID) => {
|
|
1039
|
+
const known = new Map((store.agentData(userID).sessions ?? []).map((s) => [s.sessionID, s]))
|
|
1040
|
+
return (sessionID) => {
|
|
1041
|
+
const s = known.get(sessionID)
|
|
1042
|
+
const lastSeen = s?.lastSeen ?? s?.firstSeen
|
|
1043
|
+
return {
|
|
1044
|
+
online: registry.sessionTenant(sessionID) === userID && registry.hasSession(sessionID),
|
|
1045
|
+
...(lastSeen !== undefined ? { lastSeen } : {}),
|
|
1046
|
+
...(s?.deletedAt !== undefined ? { deletedAt: s.deletedAt } : {}),
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
},
|
|
1050
|
+
})
|
|
1051
|
+
leader("session-intents", { onElected: () => intentRetention.start(), onDeposed: () => intentRetention.stop() })
|
|
1052
|
+
if (artifactMaintenance) leader("artifacts", { onElected: () => artifactMaintenance.start(), onDeposed: () => artifactMaintenance.stop() })
|
|
859
1053
|
for (const loop of leaders) void loop.start()
|
|
860
1054
|
|
|
861
1055
|
// One credential resolver for every /api request: device cookie, device token, session and app tokens.
|
|
862
1056
|
// Every stored agent message also goes to the user's console streams (live transcript rows).
|
|
863
1057
|
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 })
|
|
1058
|
+
const audit = new AgentAudit({ state: backends.state, now, admin: store, recorded: (userID, entry) => broadcast({ type: "agent", entry }, { tenant: userID }), concierge: (userID, sessionID) => isConciergeSession(conciergeIdentity, userID, sessionID) })
|
|
1059
|
+
const grants = new Grants({ state: backends.state, counters: backends.counters, directory, store, admin: store, now, concierge: async (owner) => (await conciergeIdentity.current(owner)).hiddenSessionIDs })
|
|
866
1060
|
const acting = new ActingSwitch({ store, never: config.agentsNever, audit })
|
|
867
1061
|
/** An agent as envelopes name it (a session by its directory name). */
|
|
868
1062
|
const describeActor = (userID: string, actor: AgentActor) => {
|
|
@@ -909,7 +1103,269 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
909
1103
|
void escalations.closed(tenant, requestID).catch((cause) => console.error(`[hub] escalation close failed: ${(cause as Error).message}`))
|
|
910
1104
|
})
|
|
911
1105
|
const escalate: Escalate = (escalation) => escalations.escalate(escalation)
|
|
912
|
-
const
|
|
1106
|
+
const profiles: ProfileStore = new ProfileStore({ state: backends.state, now, ack: (userID, assignment, version) => profileApplier!.ackFor(userID, assignment, version) })
|
|
1107
|
+
profileApplier = new ProfileApplier({ profiles, registry, spokes, events: agentEvents, now })
|
|
1108
|
+
profileApplier.start()
|
|
1109
|
+
const workspaceStore = new WorkspaceStore({ state: backends.state, now, ownsMachine: (userID, machineID) => ownMachine(userID, machineID) !== undefined })
|
|
1110
|
+
const workspaceMembers = new MembershipStore({ state: backends.state, workspaces: workspaceStore, now, presence: directoryPresence(directory, (userID) => store.agentData(userID).sessions ?? []) })
|
|
1111
|
+
const workspaceSupervision = new SupervisionStore({ state: backends.state, workspaces: workspaceStore, members: workspaceMembers, now })
|
|
1112
|
+
const workspaceLifecycle = new WorkspaceLifecycle({ workspaces: workspaceStore, members: workspaceMembers, supervision: workspaceSupervision, events: agentEvents })
|
|
1113
|
+
registry.onSessionDeleted((sessionID, tenant) => void workspaceLifecycle.sessionEnded(tenant, sessionID).catch((cause) => console.error(`[hub] workspace session end failed: ${(cause as Error).message}`)))
|
|
1114
|
+
const workspaces = { workspaces: workspaceStore, members: workspaceMembers, supervision: workspaceSupervision, lifecycle: workspaceLifecycle }
|
|
1115
|
+
// Team staffing (HWA-35): seats end on session deletion; launched members are staffed when their launch runs.
|
|
1116
|
+
const sessionDirectory = (userID: string, sessionID: string) => (registry.sessionTenant(sessionID) === userID ? registry.session(sessionID)?.directory : undefined)
|
|
1117
|
+
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 })
|
|
1118
|
+
registry.onSessionDeleted((sessionID, tenant) => void teamStaffing.sessionEnded(tenant, sessionID).catch((cause) => console.error(`[hub] team session end failed: ${(cause as Error).message}`)))
|
|
1119
|
+
launchFeed.subscribe((userID, launch) => void teamStaffing.launchChanged(userID, launch).catch((cause) => console.error(`[hub] team staffing launch failed: ${(cause as Error).message}`)), { origin: true })
|
|
1120
|
+
// Team intake (HWA-36): delivered to an entry seat from the fixed party app:team-intake (never the submitter).
|
|
1121
|
+
teamIntake = new TeamIntakeService({ state: backends.state, teams: teamStore, seats: teamSeats, staffing: teamStaffing, members: workspaceMembers, presence: teamPresence, events: agentEvents, now, deliver: async (userID, sessionID, text) => ({ messageID: (await delivery.send(userID, intakeActor(userID), sessionID, "msg", text)).message.id }) })
|
|
1122
|
+
// Supervisor checkpoints and batched digests (HWA-11): acknowledgments flush before answering; digests reach
|
|
1123
|
+
// supervisor sessions through Delivery (as coordinatorActor) from the workspace-coordinator leader.
|
|
1124
|
+
const workspaceCursors = journalCursors(tokenKeys, agentEvents)
|
|
1125
|
+
let workspaceCoordinator: SupervisorCoordinator | undefined
|
|
1126
|
+
const workspaceCheckpoints = new CheckpointStore({ ...workspaces, state: backends.state, cursors: workspaceCursors, now, onAck: async (u, w, m, b) => workspaceCoordinator?.acknowledged(u, w, m, b) })
|
|
1127
|
+
const coordinatorLeader = leader("workspace-coordinator", { onElected: () => workspaceCoordinator?.start(), onDeposed: () => workspaceCoordinator?.stop() })
|
|
1128
|
+
workspaceCoordinator = new SupervisorCoordinator({
|
|
1129
|
+
...workspaces,
|
|
1130
|
+
state: backends.state,
|
|
1131
|
+
events: agentEvents,
|
|
1132
|
+
cursors: workspaceCursors,
|
|
1133
|
+
checkpoints: workspaceCheckpoints,
|
|
1134
|
+
sessions: registryBoardSessions(directory, registry),
|
|
1135
|
+
send: async (userID, sessionID, text, options) => {
|
|
1136
|
+
const sent = await delivery.send(userID, coordinatorActor(userID), sessionID, "msg", text, options)
|
|
1137
|
+
return { messageID: sent.message.id, ...(sent.delivery ? { delivery: sent.delivery } : {}), ...(sent.woke !== undefined ? { woke: sent.woke } : {}) }
|
|
1138
|
+
},
|
|
1139
|
+
users: allUsers,
|
|
1140
|
+
isLeader: () => coordinatorLeader.isLeader(),
|
|
1141
|
+
owner: leaseOwner,
|
|
1142
|
+
now,
|
|
1143
|
+
...(loopTickMs !== undefined ? { tickMs: loopTickMs } : {}),
|
|
1144
|
+
})
|
|
1145
|
+
void coordinatorLeader.start() // created after the other leaders were started
|
|
1146
|
+
const teamDiscovery = new TeamDiscovery({ teams: teamStore, seats: teamSeats, directory, harnesses: catalogHarnesses(registry) })
|
|
1147
|
+
|
|
1148
|
+
// ---- CON-04 the Concierge: settings, launch, supervise, hide, context ----
|
|
1149
|
+
const conciergeSettings = new SettingsStore(backends.state, now)
|
|
1150
|
+
const conciergeNotes = new NotesStore(backends.state, now)
|
|
1151
|
+
const conciergeLabels = async (userID: string) => {
|
|
1152
|
+
const out = new Map<string, Readonly<Record<string, string>>>()
|
|
1153
|
+
for (const launch of await launches.list(userID)) if (launch.sessionID !== undefined) out.set(launch.sessionID, launch.spec.labels)
|
|
1154
|
+
return out
|
|
1155
|
+
}
|
|
1156
|
+
// The board's `armed` flag reads the tiers slot (built below).
|
|
1157
|
+
const conciergeArmed = async (userID: string) => conciergeTierHooks?.armedID?.(userID)
|
|
1158
|
+
// (CON-13) The board's inGrant: the tiers' acting engine (made below) checks whether the current Concierge's grant
|
|
1159
|
+
// would approve a pending request once, without acting.
|
|
1160
|
+
let conciergeActing: Acting | undefined
|
|
1161
|
+
const conciergeCoverage = async (userID: string, conciergeSessionID: string, sessionID: string, requestID: string) =>
|
|
1162
|
+
conciergeActing ? conciergeActing.coverage({ userID, actor: { kind: "session", userID, machineID: "", sessionID: conciergeSessionID, via: "act" } }, sessionID, requestID) : { inGrant: false, auto: false }
|
|
1163
|
+
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 }
|
|
1164
|
+
const conciergeRevisions = new BoardRevisions(backends.state)
|
|
1165
|
+
const conciergeContext = new ContextPusher({
|
|
1166
|
+
registry,
|
|
1167
|
+
send: (connID, req) => spokes.request(connID, req),
|
|
1168
|
+
board: async (userID) => (await conciergeBoard(conciergeBoardDeps, conciergeRevisions, userID, (await conciergeIdentity.current(userID)).hiddenSessionIDs)).rendered,
|
|
1169
|
+
notes: conciergeNotes,
|
|
1170
|
+
settings: conciergeSettings,
|
|
1171
|
+
grant: async (userID, grantID) => {
|
|
1172
|
+
try {
|
|
1173
|
+
const grant = await grants.get(userID, grantID)
|
|
1174
|
+
return { ...grant, state: grantState(grant, now()) }
|
|
1175
|
+
} catch {
|
|
1176
|
+
return undefined
|
|
1177
|
+
}
|
|
1178
|
+
},
|
|
1179
|
+
// (CON-09a) Voice and voice confirm are in the instructions only when speech is set up for the user.
|
|
1180
|
+
voice: (userID) => options.speech !== undefined || !speechSettings.view(speechScope(userID)).ready.reason,
|
|
1181
|
+
now,
|
|
1182
|
+
})
|
|
1183
|
+
/** The user's machines as the Concierge's eligibility sees them (store, nodes, registry). */
|
|
1184
|
+
const conciergeMachines = (userID: string): MachineFacts[] => {
|
|
1185
|
+
const online = new Set(registry.catalog().map((m) => m.id))
|
|
1186
|
+
return store
|
|
1187
|
+
.machines()
|
|
1188
|
+
.filter((m) => !m.revoked && m.ownerID === userID)
|
|
1189
|
+
.map((m) => {
|
|
1190
|
+
const node = nodes.forMachine(m.id)
|
|
1191
|
+
const own = node && node.ownerID === userID ? node : undefined
|
|
1192
|
+
const conn = registry.serviceFor(m.id)
|
|
1193
|
+
return {
|
|
1194
|
+
id: m.id,
|
|
1195
|
+
name: m.name,
|
|
1196
|
+
online: online.has(m.id) || own !== undefined,
|
|
1197
|
+
...(own ? { node: { runtimes: own.runtimes, workspaceRoots: own.workspaceRoots, draining: own.draining } } : {}),
|
|
1198
|
+
...(conn !== undefined ? { service: { launch: registry.supports(conn, "launch"), sessionContext: registry.supports(conn, "sessionContext") } } : {}),
|
|
1199
|
+
}
|
|
1200
|
+
})
|
|
1201
|
+
}
|
|
1202
|
+
let conciergeService: ConciergeService | undefined
|
|
1203
|
+
let conciergeModulesNow: ReturnType<typeof conciergeModules> | undefined
|
|
1204
|
+
const conciergeSupervisor = new Supervisor({
|
|
1205
|
+
state: backends.state,
|
|
1206
|
+
settings: conciergeSettings,
|
|
1207
|
+
launches,
|
|
1208
|
+
directory,
|
|
1209
|
+
grants,
|
|
1210
|
+
eligibility: (userID, machineID) => conciergeService!.eligibility(userID, machineID),
|
|
1211
|
+
entitlements: C1_ENTITLEMENTS,
|
|
1212
|
+
context: { push: (userID, sessionID) => conciergeContext.push(userID, sessionID, { force: true }).then(() => undefined), clear: (userID, sessionID) => conciergeContext.clear(userID, sessionID), retire: async (userID, sessionID) => {
|
|
1213
|
+
// (CON-10) Hub updates still pending for it are dropped: never delivered to (or shown waiting for) an ended session.
|
|
1214
|
+
const dropped = await delivery.dropPending(userID, sessionID).catch((cause) => (console.error(`[hub] concierge pending drop failed: ${(cause as Error).message}`), 0))
|
|
1215
|
+
// Audited as a count only (never the updates' text).
|
|
1216
|
+
if (dropped > 0) await store.audit({ actor: userID, action: "concierge.updates_dropped", target: sessionID, detail: String(dropped) }).catch(() => undefined)
|
|
1217
|
+
await conciergeContext.retire(userID, sessionID)
|
|
1218
|
+
} },
|
|
1219
|
+
onStatus: (userID, status) => {
|
|
1220
|
+
broadcast({ type: "concierge", status }, { tenant: userID })
|
|
1221
|
+
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}`))
|
|
1222
|
+
if (status.state === "disabled") void conciergeModulesNow?.notifier.disabled(userID).catch(() => undefined)
|
|
1223
|
+
},
|
|
1224
|
+
audit: async (userID, action) => void (await store.audit({ actor: userID, action })),
|
|
1225
|
+
// A session that becomes the Concierge leaves every workspace (member, master) and team seat it held: hub-owned.
|
|
1226
|
+
onAdopted: async (userID, sessionID) => {
|
|
1227
|
+
const [members, seats] = await Promise.all([workspaceLifecycle.sessionEnded(userID, sessionID), teamStaffing.sessionEnded(userID, sessionID)])
|
|
1228
|
+
if (members.length || seats.length) await store.audit({ actor: userID, action: "concierge.detached", target: sessionID, detail: `${members.length} workspace memberships, ${seats.length} team seats` })
|
|
1229
|
+
},
|
|
1230
|
+
onSuspended: (userID, sessionID) => void notifier.notify("concierge", sessionID, "Concierge", undefined, userID, { key: `suspended:${sessionID}` }).catch(() => 0),
|
|
1231
|
+
users: allUsers,
|
|
1232
|
+
now,
|
|
1233
|
+
})
|
|
1234
|
+
conciergeService = new ConciergeService({
|
|
1235
|
+
settings: conciergeSettings,
|
|
1236
|
+
notes: conciergeNotes,
|
|
1237
|
+
supervisor: conciergeSupervisor,
|
|
1238
|
+
grants,
|
|
1239
|
+
machines: conciergeMachines,
|
|
1240
|
+
entitlements: C1_ENTITLEMENTS,
|
|
1241
|
+
audit: async (userID, action) => void (await store.audit({ actor: userID, action })),
|
|
1242
|
+
// The file store buffers writes (FILE_STATE_DEBOUNCE_MS); Postgres writes through.
|
|
1243
|
+
...(backends.state.flush ? { flush: () => backends.state.flush!() } : {}),
|
|
1244
|
+
})
|
|
1245
|
+
const conciergePorts: ConciergePorts = {
|
|
1246
|
+
identity: conciergeIdentity,
|
|
1247
|
+
status: (userID) => conciergeSupervisor.status(userID),
|
|
1248
|
+
settings: (userID) => conciergeSettings.get(userID),
|
|
1249
|
+
beforeWake: async (userID) => {
|
|
1250
|
+
const { sessionID } = await conciergeIdentity.current(userID)
|
|
1251
|
+
return sessionID !== undefined ? conciergeContext.beforeWake(userID, sessionID) : false
|
|
1252
|
+
},
|
|
1253
|
+
send: async (userID, text, sendOptions) => {
|
|
1254
|
+
const { sessionID } = await conciergeIdentity.current(userID)
|
|
1255
|
+
if (sessionID === undefined) throw new AgentError("not_found", "The Concierge is not running")
|
|
1256
|
+
const sent = await delivery.send(userID, conciergeActor(userID), sessionID, "msg", text, { wake: sendOptions.wake, ...(sendOptions.urgent ? { urgent: true } : {}) })
|
|
1257
|
+
return { messageID: sent.message.id, ...(sent.woke !== undefined ? { woke: sent.woke } : {}) }
|
|
1258
|
+
},
|
|
1259
|
+
entitlements: C1_ENTITLEMENTS,
|
|
1260
|
+
users: allUsers,
|
|
1261
|
+
// CON-05 the notifier's journal (cursor), ledger store, watchlist view, board revision and the concierge lease.
|
|
1262
|
+
journal: {
|
|
1263
|
+
events: agentEvents,
|
|
1264
|
+
state: backends.state,
|
|
1265
|
+
watched: (userID, hidden) => watchedSessions(conciergeBoardDeps, userID, hidden),
|
|
1266
|
+
boardRevision: async (userID, hidden) => (await conciergeBoard(conciergeBoardDeps, conciergeRevisions, userID, hidden)).board.revision,
|
|
1267
|
+
decisionOf: async (userID, messageID) => {
|
|
1268
|
+
const decision = await decisions.byMessage(userID, messageID)
|
|
1269
|
+
return decision ? { id: decision.id, about: decision.about } : undefined
|
|
1270
|
+
},
|
|
1271
|
+
isLeader: () => conciergeLeader.isLeader(),
|
|
1272
|
+
owner: leaseOwner,
|
|
1273
|
+
now,
|
|
1274
|
+
...(loopTickMs !== undefined ? { tickMs: loopTickMs } : {}),
|
|
1275
|
+
},
|
|
1276
|
+
// CON-06: proposals run through the same acting engine as the act verbs (agent/routes.ts actVerbRoutes).
|
|
1277
|
+
tiers: {
|
|
1278
|
+
state: backends.state,
|
|
1279
|
+
counters: backends.counters,
|
|
1280
|
+
decisions,
|
|
1281
|
+
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(conciergeIdentity), conciergeActor: conciergeProtects(conciergeIdentity) })),
|
|
1282
|
+
audit,
|
|
1283
|
+
protects: conciergeProtects(conciergeIdentity),
|
|
1284
|
+
published: (userID, message) => feed.publish(userID, message),
|
|
1285
|
+
describe: describeActor,
|
|
1286
|
+
present: (deviceID, sessionID) => registry.present(deviceID, sessionID),
|
|
1287
|
+
deviceOwner: (deviceID) => store.devices().find((d) => d.id === deviceID)?.ownerID,
|
|
1288
|
+
// The board shows the armed Decision: refresh the Concierge's context too.
|
|
1289
|
+
armed: (userID, armed) => (broadcast({ type: "concierge", armed }, { tenant: userID }), conciergeTouch(userID)),
|
|
1290
|
+
now,
|
|
1291
|
+
},
|
|
1292
|
+
}
|
|
1293
|
+
const conciergeParts = conciergeModules(conciergePorts)
|
|
1294
|
+
conciergeModulesNow = conciergeParts
|
|
1295
|
+
conciergeTierHooks = conciergeParts.tiers
|
|
1296
|
+
/** The user's current Concierge session, for a debounced context refresh. */
|
|
1297
|
+
const conciergeTouch = (userID: string, soon = false) => conciergeContext.touch(userID, async () => (await conciergeIdentity.current(userID)).sessionID, soon ? { soon } : {})
|
|
1298
|
+
conciergeOnAppend = (userID, event) => {
|
|
1299
|
+
try {
|
|
1300
|
+
// (CON-13) A blocker opened or closed reaches the Concierge's board within a second.
|
|
1301
|
+
conciergeTouch(userID, event.type === "blocker.opened" || event.type === "blocker.closed")
|
|
1302
|
+
conciergeParts.notifier.observe(userID, event)
|
|
1303
|
+
} catch (cause) {
|
|
1304
|
+
console.error(`[hub] concierge append hook failed: ${(cause as Error).message}`)
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
// Kicks: a Concierge launch changed, a node connected, a spoke reported its sessions (also re-sends the context
|
|
1308
|
+
// to a restarted spoke: a new connection reports the session).
|
|
1309
|
+
launchFeed.subscribe((userID, launch) => (isConciergeLaunch(launch) ? conciergeSupervisor.kick(userID) : undefined), { origin: true })
|
|
1310
|
+
nodes.subscribe((event) => (event.type === "connected" && event.node.ownerID !== undefined ? conciergeSupervisor.kick(event.node.ownerID) : undefined), { remote: true })
|
|
1311
|
+
/**
|
|
1312
|
+
* What the spoke reports about the user's current Concierge session (each sessions frame): on another agent than
|
|
1313
|
+
* `concierge` → suspended at once (grant revoked, authority gone; only the user restarts it); without the
|
|
1314
|
+
* context revision the hub sent (a restarted or reconnected spoke) → the context is re-sent now, not debounced.
|
|
1315
|
+
*/
|
|
1316
|
+
const conciergeForced = new Map<string, number>()
|
|
1317
|
+
const conciergeReported = async (tenant: string) => {
|
|
1318
|
+
const { sessionID, hiddenSessionIDs } = await conciergeIdentity.current(tenant)
|
|
1319
|
+
// (CON-09a) A previous Concierge session a spoke reports without the retired context's revision (a restarted
|
|
1320
|
+
// spoke, or one that lost it) is retired again (locked, powerless): forced, at most once per CONCIERGE_FORCED_PUSH_MS.
|
|
1321
|
+
for (const id of hiddenSessionIDs) {
|
|
1322
|
+
if (id === sessionID || registry.sessionTenant(id) !== tenant) continue
|
|
1323
|
+
const view = registry.session(id)
|
|
1324
|
+
if (!view || view.contextRevision === conciergeContext.revisionOf(tenant, id)) continue
|
|
1325
|
+
// A reported revision that is not ours: the spoke lost it, so the same connection gets it again (forced).
|
|
1326
|
+
// None reported (a new connection, an older spoke): sent once per connection (not forced).
|
|
1327
|
+
const force = view.contextRevision !== undefined
|
|
1328
|
+
const key = `retire\n${tenant}\n${id}`
|
|
1329
|
+
const at = now()
|
|
1330
|
+
if (force && at - (conciergeForced.get(key) ?? -Infinity) < CONCIERGE_FORCED_PUSH_MS) continue
|
|
1331
|
+
// Re-read: a relaunch may have adopted this very session meanwhile (never retire the current one).
|
|
1332
|
+
if ((await conciergeIdentity.current(tenant)).sessionID === id) continue
|
|
1333
|
+
if (force) conciergeForced.set(key, at)
|
|
1334
|
+
await conciergeContext.retire(tenant, id, { force })
|
|
1335
|
+
}
|
|
1336
|
+
if (sessionID === undefined || registry.sessionTenant(sessionID) !== tenant) return
|
|
1337
|
+
const view = registry.session(sessionID)
|
|
1338
|
+
if (!view) return
|
|
1339
|
+
// OpenCode's internal agents (title, compaction, summary) are not tamper; every other agent is.
|
|
1340
|
+
if (view.agent !== undefined && view.agent !== CONCIERGE_AGENT && !INTERNAL_AGENTS.has(view.agent)) {
|
|
1341
|
+
if (!KNOWN_PRIMARY_AGENTS.has(view.agent)) console.warn(`[hub] concierge session ${sessionID} reported on unknown agent ${view.agent.slice(0, 60)}: suspending`)
|
|
1342
|
+
await conciergeSupervisor.suspend(tenant, sessionID, CONCIERGE_TAMPERED)
|
|
1343
|
+
await backends.state.flush?.()
|
|
1344
|
+
return
|
|
1345
|
+
}
|
|
1346
|
+
const revision = conciergeContext.revisionOf(tenant, sessionID)
|
|
1347
|
+
if (revision !== undefined && view.contextRevision === revision) return
|
|
1348
|
+
// At most one forced push per session every CONCIERGE_FORCED_PUSH_MS (the debounced refresh still runs).
|
|
1349
|
+
const key = `${tenant}\n${sessionID}`
|
|
1350
|
+
const at = now()
|
|
1351
|
+
if (at - (conciergeForced.get(key) ?? -Infinity) < CONCIERGE_FORCED_PUSH_MS) return
|
|
1352
|
+
conciergeForced.set(key, at)
|
|
1353
|
+
if (conciergeForced.size > 1000) conciergeForced.delete(conciergeForced.keys().next().value as string)
|
|
1354
|
+
await conciergeContext.push(tenant, sessionID, { force: true })
|
|
1355
|
+
}
|
|
1356
|
+
registry.onSessions((connID) => {
|
|
1357
|
+
const tenant = registry.connTenant(connID)
|
|
1358
|
+
if (tenant === undefined) return
|
|
1359
|
+
conciergeSupervisor.kick(tenant)
|
|
1360
|
+
conciergeTouch(tenant)
|
|
1361
|
+
void conciergeReported(tenant).catch((cause) => console.error(`[hub] concierge report check failed: ${(cause as Error).message}`))
|
|
1362
|
+
})
|
|
1363
|
+
const conciergeLeader = leader("concierge", {
|
|
1364
|
+
onElected: () => (conciergeSupervisor.start(), conciergeParts.notifier.start()),
|
|
1365
|
+
onDeposed: () => (conciergeSupervisor.stop(), conciergeParts.notifier.stop()),
|
|
1366
|
+
})
|
|
1367
|
+
void conciergeLeader.start()
|
|
1368
|
+
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: conciergeIdentity, protects: conciergeProtects(conciergeIdentity), service: conciergeService, routes: () => conciergeParts.tiers.routes(), armed: conciergeArmed, timeZone: conciergeBoardDeps.timeZone, coverage: conciergeCoverage } }
|
|
913
1369
|
const resolveActor = createResolver({
|
|
914
1370
|
cookieName: COOKIE,
|
|
915
1371
|
findDevice: (token) => deviceOfToken(token),
|
|
@@ -926,6 +1382,8 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
926
1382
|
void store.touchUser(user.id)
|
|
927
1383
|
},
|
|
928
1384
|
routes: [...agentRoutes(agentDeps), ...(options.agent?.routes ?? [])],
|
|
1385
|
+
// CON-04: a Concierge session's tokens reach only an explicit route allow-list (fail closed).
|
|
1386
|
+
gate: conciergeSessionGate(conciergeIdentity, () => conciergeParts.tiers.routes()),
|
|
929
1387
|
})
|
|
930
1388
|
|
|
931
1389
|
// POST /mcp (MCP Streamable HTTP): `hub mcp`'s tools as the bearer's caller; their /api/agent calls are dispatched
|
|
@@ -987,6 +1445,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
987
1445
|
if (method === "GET" && pathname === "/login") return login(request, url)
|
|
988
1446
|
if (oidc && method === "GET" && pathname === "/auth/login") return await authLogin(url)
|
|
989
1447
|
if (oidc && method === "GET" && pathname === "/auth/callback") return await authCallback(request, url)
|
|
1448
|
+
if (isArtifactHostPath(pathname)) return await artifactHostServer.handle(request, url)
|
|
990
1449
|
if (pathname === MCP_PATH) return await mcp(request)
|
|
991
1450
|
if (isAgentPath(pathname)) return await agent.handle(request, url)
|
|
992
1451
|
if (pathname.startsWith("/api/")) return await api(request, server, url)
|
|
@@ -1093,19 +1552,24 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1093
1552
|
),
|
|
1094
1553
|
route("GET", "/api/machines", "user", ({ user }) => Response.json({ machines: machines(user.id) })),
|
|
1095
1554
|
route("GET", "/api/events", "user", ({ request, server, user, device, url }) => {
|
|
1555
|
+
const shown = streamSessionsQuery(url)
|
|
1556
|
+
if ("problem" in shown) return error(400, shown.problem)
|
|
1096
1557
|
server.timeout(request, 0)
|
|
1097
|
-
|
|
1558
|
+
// Only the user's own sessions; others are left out silently (no hint whether they exist).
|
|
1559
|
+
const sessions = shown.sessions.filter((session) => registry.owns(user.id, session))
|
|
1098
1560
|
return events(request.signal, user.id, device.id, {
|
|
1099
1561
|
lastID: request.headers.get("last-event-id") ?? url.searchParams.get("last") ?? undefined,
|
|
1100
1562
|
lite: url.searchParams.get("lite") === "1",
|
|
1101
|
-
...(
|
|
1563
|
+
...(sessions.length ? { sessions } : {}),
|
|
1564
|
+
// A pane console learns what is streamed (an older console, with ?session= only, gets no new message).
|
|
1565
|
+
scope: shown.multi,
|
|
1102
1566
|
})
|
|
1103
1567
|
}),
|
|
1104
1568
|
// The phone's session view (hidden/pinned sessions and workspaces), per user, synced to their other devices.
|
|
1105
1569
|
route("GET", "/api/prefs/view", "user", ({ user }) => Response.json({ prefs: store.viewPrefs(user.id) }, { headers: NO_STORE })),
|
|
1106
1570
|
route("PUT", "/api/prefs/view", "user", async ({ request, user, device }) => {
|
|
1107
1571
|
const before = new Set(store.viewPrefs(user.id).hiddenSessions)
|
|
1108
|
-
const prefs = await store.setViewPrefs(user.id, viewPrefsInput(await request
|
|
1572
|
+
const prefs = await store.setViewPrefs(user.id, viewPrefsInput(await jsonBody(request)))
|
|
1109
1573
|
// The user's other devices only: never another user, and not the device that made the change.
|
|
1110
1574
|
toUser(user.id, { type: "prefs", prefs }, device.id)
|
|
1111
1575
|
// Archive changes made in the console go to the agent event journal like the agent API's.
|
|
@@ -1220,9 +1684,12 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1220
1684
|
// The caller's own settings (pairing mode: the single owner's are the server default).
|
|
1221
1685
|
route("GET", "/api/settings/speech", "user", ({ user }) => Response.json(speechSettings.view(speechScope(user.id)), { headers: NO_STORE })),
|
|
1222
1686
|
// Raw JSON: anything but an object is refused by the validator.
|
|
1223
|
-
route("PUT", "/api/settings/speech", "user", async ({ request, user }) =>
|
|
1224
|
-
|
|
1225
|
-
|
|
1687
|
+
route("PUT", "/api/settings/speech", "user", async ({ request, user }) => {
|
|
1688
|
+
const view = await speechSettings.update(await jsonBody(request), speechScope(user.id))
|
|
1689
|
+
// (CON-10) The Concierge's instructions mention voice only while speech is ready: refresh its context.
|
|
1690
|
+
conciergeTouch(user.id)
|
|
1691
|
+
return Response.json(view, { headers: NO_STORE })
|
|
1692
|
+
}),
|
|
1226
1693
|
route("GET", "/api/settings/speech/models", "user", async ({ url, user }) => {
|
|
1227
1694
|
const kind = url.searchParams.get("kind")
|
|
1228
1695
|
if (kind !== "stt" && kind !== "tts") return error(400, "Expected kind=stt or kind=tts")
|
|
@@ -1267,6 +1734,16 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1267
1734
|
return Response.json(stopped.result, { headers: NO_STORE })
|
|
1268
1735
|
}),
|
|
1269
1736
|
route("POST", "/api/sessions/:session/rename", "session", ({ request, params }) => rename(request, params.session!)),
|
|
1737
|
+
// Fork and revert (feature "fork"/"revert"; console only, audited): see docs/session-view.md.
|
|
1738
|
+
route("POST", "/api/sessions/:session/fork", "session", ({ request, params, user }) => forkSession(request, params.session!, user.id)),
|
|
1739
|
+
route("GET", "/api/sessions/:session/revert/preview", "session", async ({ params, url }) => {
|
|
1740
|
+
const messageID = messageIDInput(url.searchParams.get("messageID"))
|
|
1741
|
+
const preview = await spokes.request(featureOwner(params.session!, "revert"), { op: "revertPreview", sessionID: params.session!, messageID })
|
|
1742
|
+
return Response.json(preview, { headers: NO_STORE })
|
|
1743
|
+
}),
|
|
1744
|
+
route("POST", "/api/sessions/:session/revert", "session", ({ request, params, user }) => revertSession(request, params.session!, user.id)),
|
|
1745
|
+
route("POST", "/api/sessions/:session/revert/restore", "session", ({ params, user }) => revertEnd(params.session!, user.id, "revertRestore")),
|
|
1746
|
+
route("POST", "/api/sessions/:session/revert/commit", "session", ({ params, user }) => revertEnd(params.session!, user.id, "revertCommit")),
|
|
1270
1747
|
route("POST", "/api/sessions/:session/conversation", "session", ({ request, params }) => conversation(request, params.session!), true),
|
|
1271
1748
|
route("GET", "/api/sessions/:session/models", "session", async ({ params }) =>
|
|
1272
1749
|
Response.json(await spokes.request(ownerOf(params.session!), { op: "models", sessionID: params.session! }), { headers: NO_STORE })),
|
|
@@ -1281,7 +1758,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1281
1758
|
return Response.json({ prefs, defaults: defaults.defaults }, { headers: NO_STORE })
|
|
1282
1759
|
}),
|
|
1283
1760
|
route("PUT", "/api/sessions/:session/model-prefs", "session", async ({ request, params, user }) => {
|
|
1284
|
-
const change = modelPrefsInput(await request
|
|
1761
|
+
const change = modelPrefsInput(await jsonBody(request))
|
|
1285
1762
|
await checkModelSelections(ownerOf(params.session!), params.session!, change)
|
|
1286
1763
|
const reply = await spokes.request(ownerOf(params.session!), { op: "setModelPrefs", sessionID: params.session!, ...change })
|
|
1287
1764
|
const prefs = await store.setModelPrefs(user.id, params.session!, change)
|
|
@@ -1332,8 +1809,8 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1332
1809
|
"session",
|
|
1333
1810
|
async ({ params }) => Response.json(await spokes.request(ownerOf(params.session!), { op: "context", sessionID: params.session! })),
|
|
1334
1811
|
),
|
|
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
|
|
1812
|
+
route("POST", "/api/sessions/:session/permission", "session", ({ request, params, user }) => permission(request, params.session!, user.id)),
|
|
1813
|
+
route("POST", "/api/sessions/:session/form", "session", ({ request, params, user }) => form(request, params.session!, user.id)),
|
|
1337
1814
|
route("GET", "/api/sessions/:session/summary", "session", async ({ params }) =>
|
|
1338
1815
|
Response.json(await spokes.request(ownerOf(params.session!), { op: "summary", sessionID: params.session! }), { headers: NO_STORE })),
|
|
1339
1816
|
route("GET", "/api/sessions/:session/diff", "session", async ({ params, url }) => {
|
|
@@ -1402,6 +1879,8 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1402
1879
|
const decision = await decisions.get(user.id, params.id!)
|
|
1403
1880
|
return decision ? Response.json({ decision }, { headers: NO_STORE }) : error(404, "No such decision", "not_found")
|
|
1404
1881
|
}),
|
|
1882
|
+
// CON-06: `via: "voice" | "text"` (the Concierge panel) only for an armed confirm Decision on a device present on
|
|
1883
|
+
// the current Concierge session (tiers.ts); else 403 "Answer this decision with a tap".
|
|
1405
1884
|
route("POST", "/api/decisions/:id/answer", "user", async ({ request, params, user, device }) => {
|
|
1406
1885
|
const input = await body(request)
|
|
1407
1886
|
const via = (await resolveActor.mode(request)) === "bearer" ? "device-token" : "cookie"
|
|
@@ -1416,7 +1895,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1416
1895
|
}),
|
|
1417
1896
|
route("GET", "/api/settings/speech/default", "admin", () => Response.json(speechSettings.view(), { headers: NO_STORE })),
|
|
1418
1897
|
route("PUT", "/api/settings/speech/default", "admin", async ({ request, user }) => {
|
|
1419
|
-
const view = await speechSettings.update(await request
|
|
1898
|
+
const view = await speechSettings.update(await jsonBody(request))
|
|
1420
1899
|
await store.audit({ actor: user.id, action: "speech.default" })
|
|
1421
1900
|
return Response.json(view, { headers: NO_STORE })
|
|
1422
1901
|
}),
|
|
@@ -1646,6 +2125,64 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1646
2125
|
return connID
|
|
1647
2126
|
}
|
|
1648
2127
|
|
|
2128
|
+
/**
|
|
2129
|
+
* The process that runs a fork or revert op: the session's owner, else another process reporting it, that
|
|
2130
|
+
* announced the feature; 409 unsupported {feature, harness} when none did (another harness, an older plugin, or
|
|
2131
|
+
* an OpenCode process that cannot reach its own server).
|
|
2132
|
+
*/
|
|
2133
|
+
function featureOwner(sessionID: string, feature: "fork" | "revert"): string {
|
|
2134
|
+
const targets = registry.sessionTargets(sessionID)
|
|
2135
|
+
if (!targets.length) throw new HttpError(409, NO_OWNER)
|
|
2136
|
+
const connID = targets.find((c) => registry.supports(c, feature))
|
|
2137
|
+
if (!connID) throw unsupportedError({ feature, harness: registry.harnessOf(targets[0]!) ?? "unknown" })
|
|
2138
|
+
return connID
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
/** A user message ID from a request (1–300 ID characters), else 400. */
|
|
2142
|
+
function messageIDInput(raw: unknown): string {
|
|
2143
|
+
if (typeof raw !== "string" || !MESSAGE_ID.test(raw)) throw new HttpError(400, "Expected {messageID}: one of the session's user messages")
|
|
2144
|
+
return raw
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
/** A spoke's refusal of a revert while the session runs, as 409 busy; anything else unchanged. */
|
|
2148
|
+
function busyRefusal(cause: unknown): unknown {
|
|
2149
|
+
return cause instanceof SpokeError && cause.message === REVERT_BUSY ? new HttpError(409, REVERT_BUSY, "busy") : cause
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
/** POST …/fork {messageID}: a new session with the conversation before that user message (201). */
|
|
2153
|
+
async function forkSession(request: Request, sessionID: string, userID: string): Promise<Response> {
|
|
2154
|
+
const messageID = messageIDInput((await body(request)).messageID)
|
|
2155
|
+
const connID = featureOwner(sessionID, "fork")
|
|
2156
|
+
const result = await spokes.request(connID, { op: "fork", sessionID, messageID })
|
|
2157
|
+
await store.audit({ actor: userID, action: "session.fork", target: sessionID, detail: `${messageID} -> ${result.sessionID}` })
|
|
2158
|
+
return Response.json(result, { status: 201, headers: NO_STORE })
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
/** POST …/revert {messageID, files?}: stages a revert (files restored unless files: false); 409 while it runs. */
|
|
2162
|
+
async function revertSession(request: Request, sessionID: string, userID: string): Promise<Response> {
|
|
2163
|
+
const input = await body(request)
|
|
2164
|
+
const messageID = messageIDInput(input.messageID)
|
|
2165
|
+
if (input.files !== undefined && typeof input.files !== "boolean") return error(400, "files must be true or false")
|
|
2166
|
+
const files = input.files !== false
|
|
2167
|
+
const connID = featureOwner(sessionID, "revert")
|
|
2168
|
+
if (registry.running(sessionID)) throw new HttpError(409, REVERT_BUSY, "busy")
|
|
2169
|
+
const result = await spokes.request(connID, { op: "revert", sessionID, messageID, files }).catch((cause) => {
|
|
2170
|
+
throw busyRefusal(cause)
|
|
2171
|
+
})
|
|
2172
|
+
await store.audit({ actor: userID, action: "session.revert", target: sessionID, detail: files ? messageID : `${messageID} (messages only)` })
|
|
2173
|
+
return Response.json(result, { headers: NO_STORE })
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2176
|
+
/** POST …/revert/restore (undo) or …/revert/commit (make permanent): 204. */
|
|
2177
|
+
async function revertEnd(sessionID: string, userID: string, op: "revertRestore" | "revertCommit"): Promise<Response> {
|
|
2178
|
+
const connID = featureOwner(sessionID, "revert")
|
|
2179
|
+
await spokes.request(connID, { op, sessionID }).catch((cause) => {
|
|
2180
|
+
throw busyRefusal(cause)
|
|
2181
|
+
})
|
|
2182
|
+
await store.audit({ actor: userID, action: op === "revertRestore" ? "session.revert.restore" : "session.revert.commit", target: sessionID })
|
|
2183
|
+
return new Response(null, { status: 204 })
|
|
2184
|
+
}
|
|
2185
|
+
|
|
1649
2186
|
/** Conversation mode: steer the utterance into the session (allowed while it is busy) and return at once. */
|
|
1650
2187
|
async function utterance(request: Request, sessionID: string): Promise<Response> {
|
|
1651
2188
|
const raw = (await body(request)).text
|
|
@@ -1766,17 +2303,27 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1766
2303
|
return Response.json({ sessionID: result.sessionID, ...(openedIn ? { openedIn } : {}), ...(warning ? { warning } : {}) }, { status: 201 })
|
|
1767
2304
|
}
|
|
1768
2305
|
|
|
1769
|
-
|
|
2306
|
+
/**
|
|
2307
|
+
* (CON-13) The user's own answer on a session's card, in the agent audit as actor user (as the acting engine records
|
|
2308
|
+
* a user's act through /api/agent): Settings → Agents → Activity then shows "you denied …" next to the agents'
|
|
2309
|
+
* answers. The asking session (a subagent's request is answered through its root) is the target. Best effort.
|
|
2310
|
+
*/
|
|
2311
|
+
const auditUserAnswer = (userID: string, verb: "approve" | "deny" | "answer", target: { sessionID: string; requestID?: string; formID?: string }) =>
|
|
2312
|
+
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}`))
|
|
2313
|
+
|
|
2314
|
+
async function permission(request: Request, rootID: string, userID: string): Promise<Response> {
|
|
1770
2315
|
const input = await body(request)
|
|
1771
2316
|
const decision = input.decision as PermissionDecision
|
|
1772
2317
|
if (typeof input.requestID !== "string" || !DECISIONS.has(decision)) return error(400, "Invalid decision")
|
|
2318
|
+
const asking = registry.permissionRequest(input.requestID)?.sessionID ?? rootID
|
|
1773
2319
|
const answered = await actions.answerPermission({ rootID, requestID: input.requestID, decision })
|
|
1774
2320
|
if (answered.outcome === "unsupported") throw unsupportedError(answered)
|
|
2321
|
+
if (answered.outcome === "done") auditUserAnswer(userID, decision === "reject" ? "deny" : "approve", { sessionID: asking, requestID: input.requestID })
|
|
1775
2322
|
return answered.outcome === "done" ? new Response(null, { status: 204 }) : error(409, PERMISSION_GONE, "gone")
|
|
1776
2323
|
}
|
|
1777
2324
|
|
|
1778
2325
|
/** 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> {
|
|
2326
|
+
async function form(request: Request, rootID: string, userID: string): Promise<Response> {
|
|
1780
2327
|
const input = await body(request)
|
|
1781
2328
|
if (typeof input.formID !== "string" || !input.formID || input.formID.length > 300) return error(400, "Expected {formID, answer} or {formID, cancel: true}")
|
|
1782
2329
|
const cancel = input.cancel === true
|
|
@@ -1785,10 +2332,12 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1785
2332
|
const problem = formAnswerProblem(input.answer)
|
|
1786
2333
|
if (problem) return error(400, problem)
|
|
1787
2334
|
}
|
|
2335
|
+
const asking = registry.status(rootID)?.find((s) => s.blockers.some((b) => b.kind === "form" && b.id === input.formID))?.id ?? rootID
|
|
1788
2336
|
const answered = await actions.answerForm(
|
|
1789
2337
|
cancel ? { rootID, formID: input.formID, cancel: true } : { rootID, formID: input.formID, answer: input.answer as FormAnswer },
|
|
1790
2338
|
)
|
|
1791
2339
|
if (answered.outcome === "unsupported") throw unsupportedError(answered)
|
|
2340
|
+
if (answered.outcome === "done") auditUserAnswer(userID, "answer", { sessionID: asking, formID: input.formID })
|
|
1792
2341
|
return answered.outcome === "done" ? new Response(null, { status: 204 }) : error(409, PERMISSION_GONE, "gone")
|
|
1793
2342
|
}
|
|
1794
2343
|
|
|
@@ -1798,8 +2347,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1798
2347
|
if (Number(request.headers.get("content-length") ?? 0) > config.maxAudioBytes) {
|
|
1799
2348
|
return error(413, "Recording too large")
|
|
1800
2349
|
}
|
|
1801
|
-
const audio =
|
|
1802
|
-
if (audio.length > config.maxAudioBytes) return error(413, "Recording too large")
|
|
2350
|
+
const audio = await boundedBytes(request, config.maxAudioBytes, "Recording too large")
|
|
1803
2351
|
if (audio.length === 0) return error(400, "Empty recording")
|
|
1804
2352
|
return Response.json({ text: await speech.transcribe(audio, format) })
|
|
1805
2353
|
}
|
|
@@ -1809,7 +2357,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1809
2357
|
* after it when its log still covers the gap (one fresh catalog if catalogs changed meanwhile), else it starts
|
|
1810
2358
|
* over: the catalog, then a `resync` marker so the page refetches what it shows. Heartbeats are `ping` events.
|
|
1811
2359
|
*/
|
|
1812
|
-
function events(signal: AbortSignal, userID: string, deviceID: string, options: { lastID?: string; lite: boolean;
|
|
2360
|
+
function events(signal: AbortSignal, userID: string, deviceID: string, options: { lastID?: string; lite: boolean; sessions?: readonly string[]; scope?: boolean }): Response {
|
|
1813
2361
|
// Draining: come back (to another pod) shortly.
|
|
1814
2362
|
if (draining) return new Response(SSE_RETRY, { headers: { "content-type": "text/event-stream", "cache-control": "no-cache" } })
|
|
1815
2363
|
const encoder = new TextEncoder()
|
|
@@ -1827,7 +2375,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1827
2375
|
userID,
|
|
1828
2376
|
deviceID,
|
|
1829
2377
|
lite: options.lite,
|
|
1830
|
-
...(options.
|
|
2378
|
+
...(options.sessions ? { sessions: options.sessions } : {}),
|
|
1831
2379
|
send: (message, id) => write(`${id ? `id: ${id}\n` : ""}data: ${JSON.stringify(message)}\n\n`),
|
|
1832
2380
|
close: () => {
|
|
1833
2381
|
cleanup()
|
|
@@ -1840,6 +2388,12 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1840
2388
|
listener.close()
|
|
1841
2389
|
},
|
|
1842
2390
|
}
|
|
2391
|
+
// At once, whatever follows: Bun sends the response head with the first body chunk, and a resumed stream
|
|
2392
|
+
// with nothing to replay would otherwise stay silent until the first heartbeat, so the page's EventSource
|
|
2393
|
+
// would not open (its "Reconnecting…" showing) for up to heartbeatMs (RP-10). A comment: the page ignores it.
|
|
2394
|
+
write(": open\n\n")
|
|
2395
|
+
// Before anything else, so the page can tell a hub that streams several sessions from one that does not.
|
|
2396
|
+
if (options.scope) listener.send({ type: "scope", sessions: options.sessions ?? [] })
|
|
1843
2397
|
if (live) return clusterStream(listener, write)
|
|
1844
2398
|
eventLog.open(userID)
|
|
1845
2399
|
const missed = options.lastID ? eventLog.since(userID, options.lastID) : undefined
|
|
@@ -2012,7 +2566,11 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
2012
2566
|
launchTokens,
|
|
2013
2567
|
webhooks,
|
|
2014
2568
|
agentEvents,
|
|
2569
|
+
directory,
|
|
2570
|
+
concierge: conciergeService,
|
|
2015
2571
|
agentEventWriters,
|
|
2572
|
+
intentRetention,
|
|
2573
|
+
teams: { store: teamStore, seats: teamSeats, staffing: teamStaffing, intake: teamIntake },
|
|
2016
2574
|
routes: [...routes.map(({ method, path, access }) => ({ method, path, access })), ...agent.routes.map(({ method, path }) => ({ method, path, access: "agent" as const }))],
|
|
2017
2575
|
drain(drainOptions: DrainOptions = {}) {
|
|
2018
2576
|
draining ??= runDrain(drainOptions)
|
|
@@ -2022,7 +2580,12 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
2022
2580
|
// Step down first (releasing the leases), so another pod takes the timed jobs over at once.
|
|
2023
2581
|
await Promise.allSettled([...leaders.map((loop) => loop.stop()), recommendations?.stop()])
|
|
2024
2582
|
launches.shutdown()
|
|
2583
|
+
conciergeSupervisor.stop()
|
|
2584
|
+
conciergeContext.stop()
|
|
2585
|
+
conciergeParts.tiers.stop?.()
|
|
2025
2586
|
webhooks.stop()
|
|
2587
|
+
artifactMaintenance?.stop()
|
|
2588
|
+
profileApplier?.stop()
|
|
2026
2589
|
agentEventWriters.stop()
|
|
2027
2590
|
tokenPush.stop()
|
|
2028
2591
|
delivery.stop()
|
|
@@ -2047,18 +2610,19 @@ const optionalState = (url: URL): { state?: Decision["state"] } => {
|
|
|
2047
2610
|
return state ? { state } : {}
|
|
2048
2611
|
}
|
|
2049
2612
|
|
|
2050
|
-
/** {option?, text?} of a Decision answer (types only; Decisions validates). */
|
|
2051
|
-
function decisionAnswer(input: Record<string, unknown>): { option?: string; text?: string } {
|
|
2613
|
+
/** {option?, text?, via?} of a Decision answer (types only; Decisions validates). */
|
|
2614
|
+
function decisionAnswer(input: Record<string, unknown>): { option?: string; text?: string; via?: AnswerVia } {
|
|
2052
2615
|
for (const key of ["option", "text"] as const) {
|
|
2053
2616
|
if (input[key] !== undefined && typeof input[key] !== "string") throw new AgentError("invalid", `${key} must be a string`)
|
|
2054
2617
|
}
|
|
2055
|
-
|
|
2618
|
+
if (input.via !== undefined && !(ANSWER_VIAS as readonly unknown[]).includes(input.via)) throw new AgentError("invalid", `via is one of ${ANSWER_VIAS.join(", ")}`)
|
|
2619
|
+
return { ...(typeof input.option === "string" ? { option: input.option } : {}), ...(typeof input.text === "string" ? { text: input.text } : {}), ...(input.via !== undefined ? { via: input.via as AnswerVia } : {}) }
|
|
2056
2620
|
}
|
|
2057
2621
|
|
|
2058
2622
|
/** Console envelope for a Decision failure: gone → 409 (the web app's toast), the rest by code. */
|
|
2059
2623
|
function decisionFailure(cause: unknown): Response {
|
|
2060
2624
|
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 }
|
|
2625
|
+
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
2626
|
return Response.json({ error: cause.message, code: cause.code, ...(cause.options.hint ? { hint: cause.options.hint } : {}) }, { status: status[cause.code] ?? 500 })
|
|
2063
2627
|
}
|
|
2064
2628
|
|
|
@@ -2172,13 +2736,32 @@ function param(raw: string): string {
|
|
|
2172
2736
|
|
|
2173
2737
|
const sleep = (ms: number) => new Promise<undefined>((resolve) => setTimeout(() => resolve(undefined), ms))
|
|
2174
2738
|
|
|
2739
|
+
/**
|
|
2740
|
+
* Console and public JSON bodies: at most this many bytes (agent/body.ts bounds the bytes and time read). Generous:
|
|
2741
|
+
* a prompt may carry up to 4 images (MAX_PROMPT_IMAGES; the validator refuses more than MAX_PROMPT_IMAGES_BASE64).
|
|
2742
|
+
*/
|
|
2743
|
+
const CONSOLE_JSON_BODY_MAX = 4 << 20
|
|
2744
|
+
|
|
2745
|
+
/** The bytes of a console/public request body, or HttpError 413 (too large) / 408 (too slow). */
|
|
2746
|
+
async function boundedBytes(request: Request, max: number, tooLarge: string): Promise<Uint8Array> {
|
|
2747
|
+
const read = await readBounded(request, max)
|
|
2748
|
+
if (read.ok) return read.bytes
|
|
2749
|
+
if (read.reason === "timeout") throw new HttpError(408, slowMessage(), undefined, true)
|
|
2750
|
+
throw new HttpError(413, tooLarge, undefined, read.close)
|
|
2751
|
+
}
|
|
2752
|
+
|
|
2753
|
+
/** A console/public JSON body (a SyntaxError for invalid JSON, as request.json() threw). */
|
|
2754
|
+
async function jsonBody(request: Request): Promise<unknown> {
|
|
2755
|
+
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"))
|
|
2756
|
+
}
|
|
2757
|
+
|
|
2175
2758
|
async function body(request: Request): Promise<Record<string, unknown>> {
|
|
2176
|
-
const value =
|
|
2759
|
+
const value = await jsonBody(request)
|
|
2177
2760
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {}
|
|
2178
2761
|
}
|
|
2179
2762
|
|
|
2180
2763
|
function failure(cause: unknown): Response {
|
|
2181
|
-
if (cause instanceof HttpError) return error(cause.status, cause.message, cause.code)
|
|
2764
|
+
if (cause instanceof HttpError) return error(cause.status, cause.message, cause.code, cause.close ? { connection: "close" } : undefined)
|
|
2182
2765
|
if (cause instanceof BusyError) return error(409, cause.message)
|
|
2183
2766
|
if (cause instanceof RateLimitError) return error(429, cause.message)
|
|
2184
2767
|
if (cause instanceof ValidationError) return error(400, cause.message)
|