@huynle/hub 0.7.1 → 0.7.3
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 +87 -0
- package/Dockerfile +3 -0
- package/README.md +148 -11
- package/package.json +3 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/main.ts +4 -4
- package/packages/claude-code/src/wrapper.ts +3 -2
- package/packages/cli/dist/hub-cli.js +19334 -7849
- package/packages/cli/package.json +11 -0
- package/packages/cli/src/acting.ts +70 -0
- package/packages/cli/src/args.ts +91 -0
- package/packages/cli/src/bootstrap/adapters/archive-stream.ts +276 -0
- package/packages/cli/src/bootstrap/adapters/claude/README.md +63 -0
- package/packages/cli/src/bootstrap/adapters/claude/adapter.ts +250 -0
- package/packages/cli/src/bootstrap/adapters/claude/index.ts +42 -0
- package/packages/cli/src/bootstrap/adapters/claude/node.ts +99 -0
- package/packages/cli/src/bootstrap/adapters/claude/openpgp.ts +221 -0
- package/packages/cli/src/bootstrap/adapters/claude/ports.ts +44 -0
- package/packages/cli/src/bootstrap/adapters/claude/source.ts +160 -0
- package/packages/cli/src/bootstrap/adapters/claude/verify.ts +165 -0
- package/packages/cli/src/bootstrap/adapters/codex/archive.ts +103 -0
- package/packages/cli/src/bootstrap/adapters/codex/index.ts +60 -0
- package/packages/cli/src/bootstrap/adapters/codex/plan.ts +129 -0
- package/packages/cli/src/bootstrap/adapters/codex/sigstore.ts +298 -0
- package/packages/cli/src/bootstrap/adapters/codex/source.ts +102 -0
- package/packages/cli/src/bootstrap/adapters/codex/verify.ts +298 -0
- package/packages/cli/src/bootstrap/adapters/inflate.ts +433 -0
- package/packages/cli/src/bootstrap/adapters/node-services/control.ts +392 -0
- package/packages/cli/src/bootstrap/adapters/node-services/index.ts +23 -0
- package/packages/cli/src/bootstrap/adapters/node-services/launch.ts +223 -0
- package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +222 -0
- package/packages/cli/src/bootstrap/adapters/node-services/render.ts +183 -0
- package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +197 -0
- package/packages/cli/src/bootstrap/adapters/opencode/README.md +204 -0
- package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +205 -0
- package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +91 -0
- package/packages/cli/src/bootstrap/adapters/opencode/config.ts +212 -0
- package/packages/cli/src/bootstrap/adapters/opencode/index.ts +81 -0
- package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +266 -0
- package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +208 -0
- package/packages/cli/src/bootstrap/adapters/readiness.ts +19 -0
- package/packages/cli/src/bootstrap/ansible/index.ts +2 -0
- package/packages/cli/src/bootstrap/ansible/render.ts +279 -0
- package/packages/cli/src/bootstrap/contracts/actions.ts +470 -0
- package/packages/cli/src/bootstrap/contracts/artifact.ts +323 -0
- package/packages/cli/src/bootstrap/contracts/canonical.ts +54 -0
- package/packages/cli/src/bootstrap/contracts/credentials.ts +108 -0
- package/packages/cli/src/bootstrap/contracts/discovery.ts +215 -0
- package/packages/cli/src/bootstrap/contracts/envelope.ts +143 -0
- package/packages/cli/src/bootstrap/contracts/exec.ts +74 -0
- package/packages/cli/src/bootstrap/contracts/harness.ts +202 -0
- package/packages/cli/src/bootstrap/contracts/index.ts +28 -0
- package/packages/cli/src/bootstrap/contracts/journal.ts +198 -0
- package/packages/cli/src/bootstrap/contracts/paths.ts +91 -0
- package/packages/cli/src/bootstrap/contracts/plan.ts +155 -0
- package/packages/cli/src/bootstrap/contracts/platform.ts +62 -0
- package/packages/cli/src/bootstrap/contracts/preview.ts +190 -0
- package/packages/cli/src/bootstrap/contracts/profile.ts +105 -0
- package/packages/cli/src/bootstrap/contracts/secrets.ts +52 -0
- package/packages/cli/src/bootstrap/contracts/validate.ts +212 -0
- package/packages/cli/src/bootstrap/discovery/discover.ts +146 -0
- package/packages/cli/src/bootstrap/discovery/executables.ts +134 -0
- package/packages/cli/src/bootstrap/discovery/host.ts +96 -0
- package/packages/cli/src/bootstrap/discovery/index.ts +6 -0
- package/packages/cli/src/bootstrap/discovery/node-probe.ts +97 -0
- package/packages/cli/src/bootstrap/discovery/probe.ts +41 -0
- package/packages/cli/src/bootstrap/discovery/util.ts +13 -0
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +226 -0
- package/packages/cli/src/bootstrap/enrollment/index.ts +7 -0
- package/packages/cli/src/bootstrap/enrollment/policy.ts +54 -0
- package/packages/cli/src/bootstrap/enrollment/result.ts +63 -0
- package/packages/cli/src/bootstrap/enrollment/status.ts +119 -0
- package/packages/cli/src/bootstrap/enrollment/store.ts +173 -0
- package/packages/cli/src/bootstrap/preview/index.ts +5 -0
- package/packages/cli/src/bootstrap/preview/links.ts +31 -0
- package/packages/cli/src/bootstrap/preview/plan.ts +242 -0
- package/packages/cli/src/bootstrap/preview/preview.ts +104 -0
- package/packages/cli/src/bootstrap/preview/render.ts +30 -0
- package/packages/cli/src/bootstrap/profiles/audit.ts +87 -0
- package/packages/cli/src/bootstrap/profiles/catalog.ts +49 -0
- package/packages/cli/src/bootstrap/profiles/desired.ts +96 -0
- package/packages/cli/src/bootstrap/profiles/drift.ts +286 -0
- package/packages/cli/src/bootstrap/profiles/history.ts +263 -0
- package/packages/cli/src/bootstrap/profiles/index.ts +11 -0
- package/packages/cli/src/bootstrap/profiles/upgrade.ts +276 -0
- package/packages/cli/src/bootstrap/profiles/versions.ts +50 -0
- package/packages/cli/src/bootstrap/release/README.md +115 -0
- package/packages/cli/src/bootstrap/release/assets.ts +39 -0
- package/packages/cli/src/bootstrap/release/cli.ts +332 -0
- package/packages/cli/src/bootstrap/release/harness-pins.json +94 -0
- package/packages/cli/src/bootstrap/release/manifest.ts +118 -0
- package/packages/cli/src/bootstrap/release/pins.ts +192 -0
- package/packages/cli/src/bootstrap/release/text-assets.d.ts +9 -0
- package/packages/cli/src/bootstrap/release/trust-root.json +13 -0
- package/packages/cli/src/bootstrap/release/trust-root.ts +39 -0
- package/packages/cli/src/bootstrap/release/verify.ts +88 -0
- package/packages/cli/src/bootstrap/transaction/builtin.ts +312 -0
- package/packages/cli/src/bootstrap/transaction/engine.ts +994 -0
- package/packages/cli/src/bootstrap/transaction/executor.ts +112 -0
- package/packages/cli/src/bootstrap/transaction/fs.ts +194 -0
- package/packages/cli/src/bootstrap/transaction/index.ts +12 -0
- package/packages/cli/src/bootstrap/transaction/json-merge.ts +403 -0
- package/packages/cli/src/bootstrap/transaction/lock.ts +132 -0
- package/packages/cli/src/bootstrap/transaction/resolve.ts +48 -0
- package/packages/cli/src/bootstrap/transaction/result.ts +60 -0
- package/packages/cli/src/bootstrap/transaction/state.ts +88 -0
- package/packages/cli/src/bootstrap/transaction/store.ts +189 -0
- package/packages/cli/src/bootstrap/transaction/stream.ts +254 -0
- package/packages/cli/src/cli.ts +814 -0
- package/packages/cli/src/credentials.ts +75 -0
- package/packages/cli/src/decisions.ts +106 -0
- package/packages/cli/src/format.ts +185 -0
- package/packages/cli/src/grants.ts +105 -0
- package/packages/cli/src/http.ts +86 -0
- package/packages/cli/src/launches.ts +157 -0
- package/packages/cli/src/login.ts +160 -0
- package/packages/cli/src/main.ts +63 -0
- package/packages/cli/src/mcp.ts +879 -0
- package/packages/cli/src/messaging.ts +108 -0
- package/packages/cli/src/node.ts +279 -0
- package/packages/cli/src/openshell.ts +166 -0
- package/packages/cli/src/setup/commands.ts +809 -0
- package/packages/cli/src/setup/contributors.ts +171 -0
- package/packages/cli/src/setup/dispatch.ts +74 -0
- package/packages/cli/src/setup/launch-driver.ts +109 -0
- package/packages/cli/src/setup/runtime.ts +456 -0
- package/packages/cli/src/setup/spoke-probe.ts +120 -0
- package/packages/cli/src/setup/state.ts +244 -0
- package/packages/cli/src/setup/user-bin.ts +39 -0
- package/packages/cli/src/spoke-services.ts +109 -0
- package/packages/cli/src/watch.ts +28 -0
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/main.ts +3 -2
- package/packages/codex/src/spoke.ts +4 -1
- package/packages/codex/src/wrapper.ts +7 -3
- package/packages/node/package.json +1 -1
- package/packages/node/src/harnesses.ts +39 -4
- package/packages/node/src/run.ts +1 -1
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/harness-events.ts +2 -2
- package/packages/protocol/src/index.ts +70 -7
- 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/client.ts +55 -4
- package/packages/sdk/src/errors.ts +1 -0
- package/packages/sdk/src/sse.ts +4 -1
- package/packages/sdk/src/types.ts +59 -0
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/enroll.ts +26 -4
- package/src/central/agent/app-tokens.ts +27 -17
- package/src/central/agent/archive-inactive.ts +66 -0
- package/src/central/agent/directory-routes.ts +155 -12
- package/src/central/agent/directory.ts +1 -1
- package/src/central/agent/errors.ts +2 -0
- package/src/central/agent/event-cursor.ts +49 -0
- package/src/central/agent/event-writers.ts +181 -0
- package/src/central/agent/events-routes.ts +148 -0
- package/src/central/agent/events.ts +277 -0
- package/src/central/agent/names.ts +1 -1
- package/src/central/agent/read-routes.ts +32 -36
- package/src/central/agent/routes.ts +14 -11
- package/src/central/agent/watch.ts +65 -174
- package/src/central/agent/webhook-routes.ts +10 -2
- package/src/central/agent/webhooks.ts +49 -5
- package/src/central/auth.ts +127 -1
- package/src/central/backend/index.ts +3 -0
- package/src/central/backend/types.ts +2 -0
- package/src/central/http.ts +116 -32
- package/src/central/mcp-http.ts +116 -0
- package/src/central/push.ts +14 -1
- package/src/central/registry.ts +34 -5
- package/src/central/store.ts +60 -0
- package/src/config.ts +70 -7
- package/src/index.ts +90 -91
- package/src/spoke/launchers.ts +109 -3
- package/src/spoke/spoke.ts +8 -4
- package/src/spoke/timeline.ts +59 -3
- package/src/spoke/tracker.ts +59 -6
- package/src/status.ts +97 -6
- package/web/dist/apple-touch-icon.png +0 -0
- package/web/dist/assets/index-Dm8OmkaO.css +1 -0
- package/web/dist/assets/index-FUJbj_Du.js +20 -0
- package/web/dist/favicon.ico +0 -0
- package/web/dist/icon-192.png +0 -0
- package/web/dist/icon-512.png +0 -0
- package/web/dist/icon-maskable-512.png +0 -0
- package/web/dist/icon.svg +5 -5
- package/web/dist/index.html +10 -4
- package/web/dist/manifest.webmanifest +2 -2
- package/web/dist/sw.js +4 -1
- package/web/dist/assets/index-D9zMbxSg.css +0 -1
- package/web/dist/assets/index-db1z4duA.js +0 -20
package/src/central/http.ts
CHANGED
|
@@ -16,6 +16,10 @@ import { authPage, cookieValue, deviceName, OIDC_COOKIE, OidcClient, OidcError }
|
|
|
16
16
|
import { isNotificationKind, Push, type PushSender } from "./push"
|
|
17
17
|
import { pushEndpointProblem, resolveHost as systemResolveHost, type ResolveHost } from "./push-target"
|
|
18
18
|
import { Webhooks } from "./agent/webhooks"
|
|
19
|
+
import { issueAppToken } from "./agent/app-tokens"
|
|
20
|
+
import { AGENT_EVENTS_AGE_MS, AGENT_EVENTS_MAX, AgentEvents, archiveEvent } from "./agent/events"
|
|
21
|
+
import { EventWriters } from "./agent/event-writers"
|
|
22
|
+
import { MemoryEventLog } from "./backend/memory-events"
|
|
19
23
|
import { WEBHOOK_SCOPE } from "./agent/webhook-routes"
|
|
20
24
|
import { hubBackends, type Backends } from "./backend"
|
|
21
25
|
import { createResolver, type AgentActor, type BearerVerifier, type CredentialMode } from "./agent/actor"
|
|
@@ -33,11 +37,12 @@ import type { Launch } from "./agent/launch-store"
|
|
|
33
37
|
import { decisionStateQuery, MessageFeed } from "./agent/message-routes"
|
|
34
38
|
import { hubDirectory } from "./agent/directory-routes"
|
|
35
39
|
import { agentApi, agentRoutes, agentVerifiers, isAgentPath, wellKnown, type AgentRoute } from "./agent/routes"
|
|
40
|
+
import { MCP_PATH, mcpHttp } from "./mcp-http"
|
|
36
41
|
import { SessionTokens } from "./agent/session-tokens"
|
|
37
42
|
import { SessionTokenPush } from "./session-token-push"
|
|
38
43
|
import { TokenKeys } from "./token-keys"
|
|
39
44
|
import { EventLog } from "./events-log"
|
|
40
|
-
import { BusyError, Registry
|
|
45
|
+
import { BusyError, Registry } from "./registry"
|
|
41
46
|
import { createActions, type Unsupported, unsupportedMessage } from "./actions"
|
|
42
47
|
import { audioFormat, SpeechError } from "./speech/provider"
|
|
43
48
|
import { SpeechService } from "./speech/service"
|
|
@@ -52,9 +57,8 @@ import { randomBytes, randomUUID } from "node:crypto"
|
|
|
52
57
|
import { LaunchEngine } from "./agent/launch-engine"
|
|
53
58
|
import { LaunchTokens } from "./agent/launch-tokens"
|
|
54
59
|
import { LaunchFeed, launchInfo } from "./agent/launch-routes"
|
|
55
|
-
import type { WatchRegistry } from "./agent/watch"
|
|
56
60
|
import { DRAIN_RETRY_AFTER_MS, DRAIN_TIMEOUT_MS, RequestTimeoutError, RequestTooLargeError, SpokeError, SpokeHub, type DrainOptions, type Notifier, type SpokeHubOptions, type SpokeSocketData } from "./spoke-ws"
|
|
57
|
-
import { ACTIVITY_INTERVAL_MAX, ACTIVITY_INTERVAL_MIN, LOCAL_USER, PUSH_PREF_KEYS, quietHoursInput, Store, VIEW_PREF_KEYS, type DeviceRecord, type LegacyPushPrefs, type PushPrefs, type UserRecord, type ViewPrefs } from "./store"
|
|
61
|
+
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"
|
|
58
62
|
|
|
59
63
|
export interface Speech {
|
|
60
64
|
transcribe(audio: Uint8Array, format: string): Promise<string>
|
|
@@ -137,6 +141,10 @@ export interface Central {
|
|
|
137
141
|
readonly launchTokens: LaunchTokens
|
|
138
142
|
/** Webhooks (P4-T10): registrations and the outbox sender; launch changes are published to it. */
|
|
139
143
|
readonly webhooks: Webhooks
|
|
144
|
+
/** The canonical agent event journal (GET /api/agent/events, MCP events): compact events per user, 24 h / 10,000. */
|
|
145
|
+
readonly agentEvents: AgentEvents
|
|
146
|
+
/** The journal's writers (state, blockers, presence, messages, launches); settled() waits for queued appends. */
|
|
147
|
+
readonly agentEventWriters: EventWriters
|
|
140
148
|
/** The JSON API's route table (method, path, access), for audits and tests. */
|
|
141
149
|
readonly routes: readonly RouteInfo[]
|
|
142
150
|
/**
|
|
@@ -232,8 +240,6 @@ const MAX_TITLE_CHARS = 200
|
|
|
232
240
|
const MAX_HERDR_WORKSPACE_CHARS = 100
|
|
233
241
|
/** View preferences: entries per list and characters per entry. */
|
|
234
242
|
const VIEW_PREF_MAX_ENTRIES = 500
|
|
235
|
-
/** hiddenSessions holds archived sessions (Archive in the console), which add up over time. */
|
|
236
|
-
const VIEW_PREF_MAX_ARCHIVED = 5000
|
|
237
243
|
const VIEW_PREF_MAX_CHARS = 4096
|
|
238
244
|
const DECISIONS = new Set<PermissionDecision>(["once", "always", "reject"])
|
|
239
245
|
/** Longest utterance accepted for conversation mode (characters, after trimming). */
|
|
@@ -535,6 +541,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
535
541
|
...(alert.requestID ? { requestID: alert.requestID } : {}),
|
|
536
542
|
...(alert.subject ? { subject: alert.subject } : {}),
|
|
537
543
|
...(alert.key ? { key: alert.key } : {}),
|
|
544
|
+
...(alert.unlessPref ? { unlessPref: alert.unlessPref } : {}),
|
|
538
545
|
}
|
|
539
546
|
notifier.notify(alert.kind, alert.sessionID, alert.title, alert.text, alert.owner, extra).catch((cause) => {
|
|
540
547
|
console.error("[hub] push notification failed:", cause instanceof Error ? cause.message : cause)
|
|
@@ -822,20 +829,33 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
822
829
|
resolve: options.resolveHost ?? systemResolveHost,
|
|
823
830
|
...(config.webhooksAllowPrivate ? { allowPrivate: true } : {}),
|
|
824
831
|
appAlive: (userID, tokenID) => store.agentData(userID).appTokens?.some((t) => t.id === tokenID && Array.isArray(t.scopes) && t.scopes.includes(WEBHOOK_SCOPE)) === true,
|
|
832
|
+
appScopes: (userID, tokenID) => store.agentData(userID).appTokens?.find((t) => t.id === tokenID)?.scopes,
|
|
825
833
|
userAgent: `hub/${options.version ?? "0.0.0"}`,
|
|
826
834
|
})
|
|
827
|
-
launchFeed.subscribe((userID, launch) => {
|
|
828
|
-
void webhooks
|
|
829
|
-
.onLaunch(userID, launch, launchInfo(launch))
|
|
830
|
-
// Queued here, sent by the webhooks leader (kicked when that is another pod).
|
|
831
|
-
.then((queued) => (queued && !webhookLeader.isLeader() ? webhookLeader.kick() : undefined))
|
|
832
|
-
.catch((cause) => console.error(`[hub] webhook queue failed: ${(cause as Error).message}`))
|
|
833
|
-
}, { origin: true })
|
|
834
835
|
const webhookLeader = leader("webhooks", {
|
|
835
836
|
onElected: () => webhooks.start(),
|
|
836
837
|
onDeposed: () => webhooks.stop(),
|
|
837
838
|
onKick: () => void webhooks.drain().catch((cause) => console.error(`[hub] webhook delivery failed: ${(cause as Error).message}`)),
|
|
838
839
|
})
|
|
840
|
+
// The canonical agent event journal (lifecycle design, Events): each change appended once across the cluster;
|
|
841
|
+
// presence by the agent-events leader, everything else where it happens (event-writers.ts).
|
|
842
|
+
const agentEvents = new AgentEvents({
|
|
843
|
+
log: backends.agentEvents ?? new MemoryEventLog({ now, max: AGENT_EVENTS_MAX, maxAgeMs: AGENT_EVENTS_AGE_MS }),
|
|
844
|
+
kv: backends.kv,
|
|
845
|
+
now,
|
|
846
|
+
onAppend: (userID, record) => webhooks.publishCanonical(userID, record.event, record.family, record.audience).then((queued) => (queued && !webhookLeader.isLeader() ? webhookLeader.kick() : undefined)),
|
|
847
|
+
})
|
|
848
|
+
const agentEventWriters = new EventWriters({
|
|
849
|
+
events: agentEvents,
|
|
850
|
+
kv: backends.kv,
|
|
851
|
+
registry,
|
|
852
|
+
machineOwner: (machineID) => store.machines().find((m) => m.id === machineID)?.ownerID,
|
|
853
|
+
users: allUsers,
|
|
854
|
+
messages: feed,
|
|
855
|
+
launches: launchFeed,
|
|
856
|
+
})
|
|
857
|
+
agentEventWriters.start()
|
|
858
|
+
leader("agent-events", { onElected: () => agentEventWriters.elected(), onDeposed: () => agentEventWriters.deposed() })
|
|
839
859
|
for (const loop of leaders) void loop.start()
|
|
840
860
|
|
|
841
861
|
// One credential resolver for every /api request: device cookie, device token, session and app tokens.
|
|
@@ -889,26 +909,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
889
909
|
void escalations.closed(tenant, requestID).catch((cause) => console.error(`[hub] escalation close failed: ${(cause as Error).message}`))
|
|
890
910
|
})
|
|
891
911
|
const escalate: Escalate = (escalation) => escalations.escalate(escalation)
|
|
892
|
-
|
|
893
|
-
const watchRegistry = live
|
|
894
|
-
? (userID: string): WatchRegistry => ({
|
|
895
|
-
catalog: () => registry.catalog(),
|
|
896
|
-
sessionTenant: (sessionID) => registry.sessionTenant(sessionID),
|
|
897
|
-
subscribe: (listener) => {
|
|
898
|
-
const offCatalog = registry.subscribe((message) => {
|
|
899
|
-
if (message.type === "catalog") listener(message)
|
|
900
|
-
})
|
|
901
|
-
const subscription = live.subscribe(userID, (event) => {
|
|
902
|
-
if (event.message.type === "status") listener(event.message as unknown as RegistryMessage, userID)
|
|
903
|
-
})
|
|
904
|
-
return () => {
|
|
905
|
-
offCatalog()
|
|
906
|
-
subscription.close()
|
|
907
|
-
}
|
|
908
|
-
},
|
|
909
|
-
})
|
|
910
|
-
: undefined
|
|
911
|
-
const agentDeps = { draining: drainSignal.signal, store, sessionTokens, directory, registry, spokes, delivery, decisions, feed, heartbeatMs, now, audit, grants, grantRequests, acting, actions, escalate, launches, launchFeed, webhooks, maxPromptChars: MAX_PROMPT_CHARS, ...(watchRegistry ? { watchRegistry } : {}) }
|
|
912
|
+
const agentDeps = { 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 }
|
|
912
913
|
const resolveActor = createResolver({
|
|
913
914
|
cookieName: COOKIE,
|
|
914
915
|
findDevice: (token) => deviceOfToken(token),
|
|
@@ -927,6 +928,16 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
927
928
|
routes: [...agentRoutes(agentDeps), ...(options.agent?.routes ?? [])],
|
|
928
929
|
})
|
|
929
930
|
|
|
931
|
+
// POST /mcp (MCP Streamable HTTP): `hub mcp`'s tools as the bearer's caller; their /api/agent calls are dispatched
|
|
932
|
+
// to this handler in process.
|
|
933
|
+
const mcp = mcpHttp({
|
|
934
|
+
publicURL: () => publicURL,
|
|
935
|
+
isOwnOrigin: (origin) => origins.has(origin),
|
|
936
|
+
verify: async (token) => (await resolveActor(new Request(`${url}${MCP_PATH}`, { headers: { authorization: `Bearer ${token}` } }))).ok,
|
|
937
|
+
dispatch: async (request) => (await handle(request, server)) ?? error(500, "No response"),
|
|
938
|
+
version: options.version ?? "0.0.0",
|
|
939
|
+
})
|
|
940
|
+
|
|
930
941
|
async function handle(request: Request, server: Server<SpokeSocketData>): Promise<Response | undefined> {
|
|
931
942
|
const url = new URL(request.url)
|
|
932
943
|
const { pathname } = url
|
|
@@ -963,12 +974,20 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
963
974
|
if (poll.status === "approved") broadcast({ type: "catalog" })
|
|
964
975
|
return Response.json(poll)
|
|
965
976
|
}
|
|
977
|
+
// `hub login` without a token (CLI login device code): no credential; the device code is the poller's secret.
|
|
978
|
+
if (method === "POST" && pathname === "/cli/login") {
|
|
979
|
+
const input = await body(request)
|
|
980
|
+
return Response.json(await auth.startCliLogin(input.clientName, input.scopes), { headers: NO_STORE })
|
|
981
|
+
}
|
|
982
|
+
if (method === "POST" && pathname === "/cli/login/poll") return await cliLoginPoll((await body(request)).deviceCode)
|
|
983
|
+
if (method === "GET" && pathname === "/spoke/presence") return await spokePresence(request)
|
|
966
984
|
if (method === "GET" && pathname === "/.well-known/hub") {
|
|
967
985
|
return wellKnown({ publicURL, version: options.version ?? "0.0.0", auth: config.auth.mode === "oidc" ? { mode: "oidc", issuer: config.auth.issuer } : { mode: "pairing" } })
|
|
968
986
|
}
|
|
969
987
|
if (method === "GET" && pathname === "/login") return login(request, url)
|
|
970
988
|
if (oidc && method === "GET" && pathname === "/auth/login") return await authLogin(url)
|
|
971
989
|
if (oidc && method === "GET" && pathname === "/auth/callback") return await authCallback(request, url)
|
|
990
|
+
if (pathname === MCP_PATH) return await mcp(request)
|
|
972
991
|
if (isAgentPath(pathname)) return await agent.handle(request, url)
|
|
973
992
|
if (pathname.startsWith("/api/")) return await api(request, server, url)
|
|
974
993
|
if (method === "GET") return await staticFile(pathname, request.headers.get("accept-encoding"))
|
|
@@ -978,6 +997,31 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
978
997
|
}
|
|
979
998
|
}
|
|
980
999
|
|
|
1000
|
+
/**
|
|
1001
|
+
* GET /spoke/presence: which harness spokes of the caller's own machine are connected (machine token only). The
|
|
1002
|
+
* one read-only query a machine credential can make about itself, for `hubctl setup verify` (spoke-connected
|
|
1003
|
+
* readiness): harness, evidence level and features only, never sessions, directories, pids or other machines.
|
|
1004
|
+
*/
|
|
1005
|
+
async function spokePresence(request: Request): Promise<Response> {
|
|
1006
|
+
const refused = () => Response.json({ error: "Invalid or revoked machine token" }, { status: 401 })
|
|
1007
|
+
const token = /^Bearer\s+(\S+)$/i.exec(request.headers.get("authorization") ?? "")?.[1]
|
|
1008
|
+
if (!token || token.startsWith("hlt1.")) return refused()
|
|
1009
|
+
const machine = await store.findMachine(token)
|
|
1010
|
+
if (machine) await store.recheck({ users: [machine.ownerID] })
|
|
1011
|
+
const owner = machine && store.user(machine.ownerID)
|
|
1012
|
+
if (!machine || machine.revoked || !owner || owner.disabled) return refused()
|
|
1013
|
+
const processes = registry.catalog().find((m) => m.id === machine.id)?.processes ?? []
|
|
1014
|
+
const seen = new Set<string>()
|
|
1015
|
+
const spokes = processes.flatMap((p) => {
|
|
1016
|
+
const entry = { harness: p.harness, evidence: p.evidence, features: [...p.features] }
|
|
1017
|
+
const key = JSON.stringify(entry)
|
|
1018
|
+
if (seen.has(key)) return []
|
|
1019
|
+
seen.add(key)
|
|
1020
|
+
return [entry]
|
|
1021
|
+
})
|
|
1022
|
+
return Response.json({ machineID: machine.id, spokes })
|
|
1023
|
+
}
|
|
1024
|
+
|
|
981
1025
|
async function login(request: Request, url: URL): Promise<Response> {
|
|
982
1026
|
const next = url.searchParams.get("next") ?? "/"
|
|
983
1027
|
if (!LOGIN_NEXT.test(next)) return error(400, "Invalid next path")
|
|
@@ -1060,12 +1104,24 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1060
1104
|
// The phone's session view (hidden/pinned sessions and workspaces), per user, synced to their other devices.
|
|
1061
1105
|
route("GET", "/api/prefs/view", "user", ({ user }) => Response.json({ prefs: store.viewPrefs(user.id) }, { headers: NO_STORE })),
|
|
1062
1106
|
route("PUT", "/api/prefs/view", "user", async ({ request, user, device }) => {
|
|
1107
|
+
const before = new Set(store.viewPrefs(user.id).hiddenSessions)
|
|
1063
1108
|
const prefs = await store.setViewPrefs(user.id, viewPrefsInput(await request.json()))
|
|
1064
1109
|
// The user's other devices only: never another user, and not the device that made the change.
|
|
1065
1110
|
toUser(user.id, { type: "prefs", prefs }, device.id)
|
|
1111
|
+
// Archive changes made in the console go to the agent event journal like the agent API's.
|
|
1112
|
+
const after = new Set(prefs.hiddenSessions)
|
|
1113
|
+
const changes = [...[...after].filter((id) => !before.has(id)).map((id) => archiveEvent(id, true)), ...[...before].filter((id) => !after.has(id)).map((id) => archiveEvent(id, false))]
|
|
1114
|
+
for (const change of changes) await agentEvents.append(user.id, change).catch((cause) => console.error(`[hub] archive event failed: ${(cause as Error).message}`))
|
|
1066
1115
|
return Response.json({ prefs }, { headers: NO_STORE })
|
|
1067
1116
|
}),
|
|
1068
1117
|
route("GET", "/api/reads", "user", ({ user }) => Response.json({ reads: store.reads(user.id) }, { headers: NO_STORE })),
|
|
1118
|
+
// A CLI login (`hub login`): the user approves it as themselves, with the scopes they tick, or denies it.
|
|
1119
|
+
route("GET", "/api/cli-logins/:code", "user", async ({ params }) => {
|
|
1120
|
+
const login = await auth.cliLogin(params.code!)
|
|
1121
|
+
return login ? Response.json({ login }, { headers: NO_STORE }) : error(404, "No pending CLI login with that code")
|
|
1122
|
+
}),
|
|
1123
|
+
route("POST", "/api/cli-logins/:code/approve", "user", async ({ request, params, user }) => decideCliLogin(params.code!, "approved", user, (await body(request)).scopes)),
|
|
1124
|
+
route("POST", "/api/cli-logins/:code/deny", "user", ({ params, user }) => decideCliLogin(params.code!, "denied", user)),
|
|
1069
1125
|
route("POST", "/api/enrollments/:code/approve", "user", ({ params, user }) => decideEnrollment(params.code!, "approved", user)),
|
|
1070
1126
|
route("POST", "/api/enrollments/:code/deny", "user", ({ params, user }) => decideEnrollment(params.code!, "denied", user)),
|
|
1071
1127
|
route("GET", "/api/devices", "user", ({ device, user }) =>
|
|
@@ -1431,6 +1487,31 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1431
1487
|
return new Response(null, { status: 204 })
|
|
1432
1488
|
}
|
|
1433
1489
|
|
|
1490
|
+
async function decideCliLogin(code: string, status: "approved" | "denied", user: UserRecord, scopes?: unknown): Promise<Response> {
|
|
1491
|
+
const clientName = await auth.decideCliLogin(code, status, user.id, scopes)
|
|
1492
|
+
if (clientName === undefined) return error(404, "No pending CLI login with that code")
|
|
1493
|
+
await store.audit({ actor: user.id, action: status === "approved" ? "cli-login.approve" : "cli-login.deny", detail: clientName })
|
|
1494
|
+
return new Response(null, { status: 204 })
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
/**
|
|
1498
|
+
* The decision on a CLI login, once. Approved: a new app token of the approver (still active) with the scopes
|
|
1499
|
+
* they chose, in this response only (no-store, never logged).
|
|
1500
|
+
*/
|
|
1501
|
+
async function cliLoginPoll(deviceCode: unknown): Promise<Response> {
|
|
1502
|
+
const poll = await auth.pollCliLogin(deviceCode)
|
|
1503
|
+
if (poll.status !== "approved") return Response.json({ status: poll.status }, { headers: NO_STORE })
|
|
1504
|
+
const owner = store.user(poll.ownerID)
|
|
1505
|
+
if (!owner || owner.disabled) return Response.json({ status: "denied" }, { headers: NO_STORE })
|
|
1506
|
+
try {
|
|
1507
|
+
const { record, token } = await issueAppToken(store, owner.id, { label: `CLI login: ${poll.clientName}`, scopes: poll.scopes })
|
|
1508
|
+
return Response.json({ status: "approved", token, tokenID: record.id, scopes: record.scopes }, { headers: NO_STORE })
|
|
1509
|
+
} catch (cause) {
|
|
1510
|
+
if (cause instanceof AgentError && cause.code === "rate_limited") return error(429, cause.message, "rate_limited")
|
|
1511
|
+
throw cause
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1434
1515
|
async function revokeMachine(id: string, user: UserRecord): Promise<Response> {
|
|
1435
1516
|
if (!await store.revokeMachine(id)) return error(404, "No such machine")
|
|
1436
1517
|
await store.bumpMachineEpoch(id)
|
|
@@ -1930,6 +2011,8 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1930
2011
|
launchFeed,
|
|
1931
2012
|
launchTokens,
|
|
1932
2013
|
webhooks,
|
|
2014
|
+
agentEvents,
|
|
2015
|
+
agentEventWriters,
|
|
1933
2016
|
routes: [...routes.map(({ method, path, access }) => ({ method, path, access })), ...agent.routes.map(({ method, path }) => ({ method, path, access: "agent" as const }))],
|
|
1934
2017
|
drain(drainOptions: DrainOptions = {}) {
|
|
1935
2018
|
draining ??= runDrain(drainOptions)
|
|
@@ -1940,6 +2023,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1940
2023
|
await Promise.allSettled([...leaders.map((loop) => loop.stop()), recommendations?.stop()])
|
|
1941
2024
|
launches.shutdown()
|
|
1942
2025
|
webhooks.stop()
|
|
2026
|
+
agentEventWriters.stop()
|
|
1943
2027
|
tokenPush.stop()
|
|
1944
2028
|
delivery.stop()
|
|
1945
2029
|
decisions.stop()
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// POST /mcp: the hub's MCP tools over MCP Streamable HTTP, for MCP clients and gateways (Brain decision txznll2f).
|
|
2
|
+
// Stateless: one JSON-RPC message per POST, answered as application/json (no SSE stream, no Mcp-Session-Id);
|
|
3
|
+
// notifications and client responses get 202. Batches are refused (-32600, as in protocol 2025-06-18).
|
|
4
|
+
//
|
|
5
|
+
// Auth: `Authorization: Bearer <token>`, any credential the agent API accepts (app, device, session and act
|
|
6
|
+
// tokens), checked up front with the agent API's resolver: none or invalid → 401 with `WWW-Authenticate: Bearer
|
|
7
|
+
// realm="hub"` (no OAuth resource metadata, so gateways detect plain bearer auth). The tools are `hub mcp`'s
|
|
8
|
+
// (packages/cli/src/mcp.ts) and call /api/agent with the caller's bearer, dispatched in process: scopes, grants,
|
|
9
|
+
// tenancy, kill switches and the audit are exactly the HTTP API's. A browser Origin other than the hub's own is
|
|
10
|
+
// refused (DNS rebinding); requests without one (servers, gateways) are fine. Tokens are never echoed.
|
|
11
|
+
import { createMcpServer, MCP_PROTOCOL_VERSIONS, NOT_FROM_USER, RPC, rpcError, type RpcResponse } from "../../packages/cli/src/mcp"
|
|
12
|
+
import { readCredentials } from "./agent/actor"
|
|
13
|
+
|
|
14
|
+
export const MCP_PATH = "/mcp"
|
|
15
|
+
|
|
16
|
+
/** Largest request body (bytes). */
|
|
17
|
+
export const MCP_MAX_BODY_BYTES = 1 << 20
|
|
18
|
+
|
|
19
|
+
export const MCP_INSTRUCTIONS =
|
|
20
|
+
"Hub: see and steer your AI agent sessions. Tools read sessions (status, sessions, show, log, who), message them (msg, ask, reply, decline, inbox), act within your grants (approve, deny, answer, prompt, stop) and launch sessions (launch, launches, launch_show, launch_stop), as this bearer token. " +
|
|
21
|
+
NOT_FROM_USER
|
|
22
|
+
|
|
23
|
+
export interface McpHttpDeps {
|
|
24
|
+
/** The hub's public URL (the tools' base URL; requests never leave the process). */
|
|
25
|
+
publicURL(): string
|
|
26
|
+
/** Whether a request's Origin is the hub's own. */
|
|
27
|
+
isOwnOrigin(origin: string): boolean
|
|
28
|
+
/** Whether the agent API accepts this bearer token. */
|
|
29
|
+
verify(token: string): Promise<boolean>
|
|
30
|
+
/** The hub's own request handler (the tools' /api/agent calls). */
|
|
31
|
+
dispatch(request: Request): Promise<Response>
|
|
32
|
+
readonly version: string
|
|
33
|
+
readonly maxBodyBytes?: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const NO_STORE = { "cache-control": "no-store" }
|
|
37
|
+
|
|
38
|
+
const failure = (status: number, code: string, message: string, headers: Record<string, string> = {}) =>
|
|
39
|
+
Response.json({ error: { code, message } }, { status, headers: { ...NO_STORE, ...headers } })
|
|
40
|
+
|
|
41
|
+
const unauthorized = (message: string) => failure(401, "unauthorized", message, { "www-authenticate": 'Bearer realm="hub"' })
|
|
42
|
+
|
|
43
|
+
const answer = (response: RpcResponse) =>
|
|
44
|
+
// A message the server could not take (parse error, not a request, a batch) carries no id: HTTP 400.
|
|
45
|
+
Response.json(response, { status: response.error && response.id === null ? 400 : 200, headers: NO_STORE })
|
|
46
|
+
|
|
47
|
+
/** The body as text, or undefined when it is over `max` bytes (read no further). */
|
|
48
|
+
async function readCapped(request: Request, max: number): Promise<string | undefined> {
|
|
49
|
+
const length = Number(request.headers.get("content-length") ?? "")
|
|
50
|
+
if (Number.isFinite(length) && length > max) return undefined
|
|
51
|
+
if (!request.body) return ""
|
|
52
|
+
const reader = request.body.getReader()
|
|
53
|
+
const chunks: Uint8Array[] = []
|
|
54
|
+
let size = 0
|
|
55
|
+
for (;;) {
|
|
56
|
+
const { done, value } = await reader.read()
|
|
57
|
+
if (done) break
|
|
58
|
+
size += value.byteLength
|
|
59
|
+
if (size > max) {
|
|
60
|
+
void reader.cancel().catch(() => {})
|
|
61
|
+
return undefined
|
|
62
|
+
}
|
|
63
|
+
chunks.push(value)
|
|
64
|
+
}
|
|
65
|
+
return new TextDecoder().decode(Buffer.concat(chunks))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const acceptsJSON = (accept: string | null) => {
|
|
69
|
+
if (accept === null || !accept.trim()) return true
|
|
70
|
+
return accept.split(",").some((part) => {
|
|
71
|
+
const type = part.split(";")[0]!.trim().toLowerCase()
|
|
72
|
+
return type === "application/json" || type === "*/*" || type === "application/*"
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Answers a request to /mcp (any method). */
|
|
77
|
+
export function mcpHttp(deps: McpHttpDeps): (request: Request) => Promise<Response> {
|
|
78
|
+
const max = deps.maxBodyBytes ?? MCP_MAX_BODY_BYTES
|
|
79
|
+
return async (request) => {
|
|
80
|
+
if (request.method !== "POST") return failure(405, "method_not_allowed", "POST JSON-RPC messages to /mcp (no SSE stream, no sessions)", { allow: "POST" })
|
|
81
|
+
const origin = request.headers.get("origin")
|
|
82
|
+
if (origin !== null && !deps.isOwnOrigin(origin)) return failure(403, "forbidden", "Cross-origin request refused")
|
|
83
|
+
// The bearer only: a cookie never authenticates /mcp.
|
|
84
|
+
const token = readCredentials(request, "").bearer
|
|
85
|
+
if (token === undefined) return unauthorized("Send Authorization: Bearer <hub token>")
|
|
86
|
+
if (!token || !(await deps.verify(token))) return unauthorized("Invalid, expired or revoked token")
|
|
87
|
+
const version = request.headers.get("mcp-protocol-version")
|
|
88
|
+
if (version !== null && !MCP_PROTOCOL_VERSIONS.includes(version)) return failure(400, "invalid", `Unsupported MCP-Protocol-Version (supported: ${MCP_PROTOCOL_VERSIONS.join(", ")})`)
|
|
89
|
+
const type = (request.headers.get("content-type") ?? "").split(";")[0]!.trim().toLowerCase()
|
|
90
|
+
if (type !== "application/json") return failure(415, "invalid", "Content-Type must be application/json")
|
|
91
|
+
if (!acceptsJSON(request.headers.get("accept"))) return failure(406, "invalid", "Accept must allow application/json")
|
|
92
|
+
const text = await readCapped(request, max)
|
|
93
|
+
if (text === undefined) return failure(413, "invalid", `Request body over ${max} bytes`)
|
|
94
|
+
let message: unknown
|
|
95
|
+
try {
|
|
96
|
+
message = JSON.parse(text)
|
|
97
|
+
} catch {
|
|
98
|
+
return answer(rpcError(null, RPC.parse, "parse error: not JSON"))
|
|
99
|
+
}
|
|
100
|
+
const server = createMcpServer({
|
|
101
|
+
credentials: () => ({ url: deps.publicURL(), token, source: "http", fetch: (input, init) => deps.dispatch(new Request(input, init)) }),
|
|
102
|
+
version: deps.version,
|
|
103
|
+
instructions: MCP_INSTRUCTIONS,
|
|
104
|
+
})
|
|
105
|
+
// The client went away: its tool calls are aborted.
|
|
106
|
+
const close = () => server.close()
|
|
107
|
+
request.signal.addEventListener("abort", close, { once: true })
|
|
108
|
+
try {
|
|
109
|
+
const response = await server.handle(message)
|
|
110
|
+
return response ? answer(response) : new Response(null, { status: 202, headers: NO_STORE })
|
|
111
|
+
} finally {
|
|
112
|
+
request.signal.removeEventListener("abort", close)
|
|
113
|
+
server.close()
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
package/src/central/push.ts
CHANGED
|
@@ -32,6 +32,7 @@ export const NOTIFICATION_LABELS: Readonly<Record<NotificationKind, string>> = O
|
|
|
32
32
|
activity: "Activity",
|
|
33
33
|
decision: "Decision",
|
|
34
34
|
message: "Message",
|
|
35
|
+
providerError: "Model provider error",
|
|
35
36
|
})
|
|
36
37
|
|
|
37
38
|
/** True for a known notification kind (API input). */
|
|
@@ -47,6 +48,8 @@ export interface PushExtra {
|
|
|
47
48
|
readonly key?: string
|
|
48
49
|
/** decision: the Decision, so a tap opens its card. */
|
|
49
50
|
readonly decisionID?: string
|
|
51
|
+
/** Skip devices that have this kind on (they were notified through it instead). Not sent. */
|
|
52
|
+
readonly unlessPref?: NotificationKind
|
|
50
53
|
}
|
|
51
54
|
|
|
52
55
|
export interface PushPayload {
|
|
@@ -58,6 +61,8 @@ export interface PushPayload {
|
|
|
58
61
|
readonly decisionID?: string
|
|
59
62
|
/** A test notification shaped like `kind`. */
|
|
60
63
|
readonly test?: true
|
|
64
|
+
/** Per-session kinds (providerError, subagentFailed): the session it is about, so the service worker tags each apart. */
|
|
65
|
+
readonly tagKey?: string
|
|
61
66
|
}
|
|
62
67
|
|
|
63
68
|
const minutes = (clock: string): number => Number(clock.slice(0, 2)) * 60 + Number(clock.slice(3, 5))
|
|
@@ -188,6 +193,8 @@ function kindTitle(kind: NotificationKind, subject: string | undefined): string
|
|
|
188
193
|
}
|
|
189
194
|
|
|
190
195
|
const OPEN = "Tap to open the session."
|
|
196
|
+
/** A session ID: the only key a payload may carry (job and tool IDs stay on the hub). */
|
|
197
|
+
const SESSION_ID = /^ses_[A-Za-z0-9]{1,64}$/
|
|
191
198
|
|
|
192
199
|
export function pushPayload(kind: NotificationKind, sessionID: string, sessionTitle: string, text?: string, extra: PushExtra = {}): PushPayload {
|
|
193
200
|
const name = shorten(sessionTitle)
|
|
@@ -213,6 +220,9 @@ export function pushPayload(kind: NotificationKind, sessionID: string, sessionTi
|
|
|
213
220
|
return { kind, sessionID, title, body: detail || "Nothing is running or waiting. Tap to open." }
|
|
214
221
|
case "activity":
|
|
215
222
|
return { kind, sessionID, title, body: detail || "New activity." }
|
|
223
|
+
case "providerError":
|
|
224
|
+
case "subagentFailed":
|
|
225
|
+
return { kind, sessionID, title, body: detail || OPEN, ...(extra.key && SESSION_ID.test(extra.key) ? { tagKey: extra.key } : {}) }
|
|
216
226
|
default:
|
|
217
227
|
return { kind, sessionID, title, body: detail || OPEN }
|
|
218
228
|
}
|
|
@@ -229,6 +239,7 @@ export function sendOptionsFor(kind: NotificationKind | "test"): { TTL: number;
|
|
|
229
239
|
case "failed":
|
|
230
240
|
case "decision":
|
|
231
241
|
case "message":
|
|
242
|
+
case "providerError":
|
|
232
243
|
return { TTL: URGENT_TTL_SECONDS, urgency: "high" }
|
|
233
244
|
case "done":
|
|
234
245
|
case "stopped":
|
|
@@ -385,7 +396,9 @@ export class Push {
|
|
|
385
396
|
const targets = this.store.pushSubscriptions().filter((s) => {
|
|
386
397
|
const owner = owners.get(s.deviceID)
|
|
387
398
|
if (ownerID !== undefined && owner !== ownerID) return false
|
|
388
|
-
|
|
399
|
+
const prefs = this.store.pushPrefs(s.deviceID)
|
|
400
|
+
if (!prefs[precise]) return false
|
|
401
|
+
if (extra?.unlessPref && prefs[extra.unlessPref]) return false
|
|
389
402
|
if (precise === "handoff") return true
|
|
390
403
|
const quiet = this.store.quietHours(s.deviceID)
|
|
391
404
|
return !isMuted(owner) && !(quiet && inQuietHours(quiet, at))
|
package/src/central/registry.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// holds the connection; remote state is never re-exported.
|
|
13
13
|
import path from "node:path"
|
|
14
14
|
import { HARNESS_OPENCODE, helloHarness, spokeFeatures, type EvidenceLevel, type ForwardedEvent, type Hello, type HerdrWorkspace, type ProcessKind, type SessionInfo, type SpokeFeature, type SpokeMode, type TerminalInfo, type Terminals } from "../protocol"
|
|
15
|
-
import { StatusBoard, permissionRequest, type Blocker, type BlockerRecommendation, type PermissionRequest, type SessionStatus } from "../status"
|
|
15
|
+
import { StatusBoard, errorTypeLabel, permissionRequest, providerFailureNeedsUser, providerRetryNeedsUser, type Blocker, type BlockerRecommendation, type PermissionRequest, type SessionStatus } from "../status"
|
|
16
16
|
import { sanitizeLabel } from "./agent/envelope"
|
|
17
17
|
import { DoneWatch } from "./done"
|
|
18
18
|
import { SentenceStream } from "./say-stream"
|
|
@@ -150,6 +150,8 @@ export interface Alert {
|
|
|
150
150
|
/** Tenant (user ID) that owns the session; push goes to this user's devices only. */
|
|
151
151
|
readonly owner?: string
|
|
152
152
|
readonly step?: ActivityStep
|
|
153
|
+
/** Skip devices that have this kind on (they were told through it instead). */
|
|
154
|
+
readonly unlessPref?: NotificationKind
|
|
153
155
|
}
|
|
154
156
|
|
|
155
157
|
/** Notifications that reach a session even while some device shows it. */
|
|
@@ -407,6 +409,8 @@ export class Registry {
|
|
|
407
409
|
private readonly forms = new Map<string, { connIDs: string[]; sessionID: string }>()
|
|
408
410
|
/** Form IDs already alerted (forms may be forwarded by several processes). */
|
|
409
411
|
private readonly alertedForms = new Set<string>()
|
|
412
|
+
/** Sessions whose current run raised a providerError notification (once per run; cleared when a run starts). */
|
|
413
|
+
private readonly providerAlerted = new Set<string>()
|
|
410
414
|
/** Sessions in conversation mode → the connection last told so (re-sent when another one takes over). */
|
|
411
415
|
private readonly conversations = new Map<string, string | undefined>()
|
|
412
416
|
/** `messageID ordinal` of texts already sent as say events (several processes forward the same text). */
|
|
@@ -1433,15 +1437,27 @@ export class Registry {
|
|
|
1433
1437
|
if (!rootID) return
|
|
1434
1438
|
const root = rootID === sessionID
|
|
1435
1439
|
const wasRunning = before?.state === "working" || before?.state === "blocked"
|
|
1436
|
-
if (type === "session.execution.started")
|
|
1440
|
+
if (type === "session.execution.started") {
|
|
1441
|
+
this.providerAlerted.delete(sessionID)
|
|
1442
|
+
return this.doneWatch.started(rootID, root)
|
|
1443
|
+
}
|
|
1437
1444
|
if (type === "session.text.delta" || type === "session.reasoning.delta") return
|
|
1438
1445
|
const ended = type === "session.execution.succeeded" ? "succeeded" : type === "session.execution.failed" ? "failed" : type === "session.execution.interrupted" ? "interrupted" : undefined
|
|
1439
1446
|
const reason = (data.error as { message?: unknown } | undefined)?.message
|
|
1440
1447
|
const error = typeof reason === "string" && reason.trim() ? reason : undefined
|
|
1448
|
+
const errorType = (data.error as { type?: unknown } | undefined)?.type
|
|
1449
|
+
// A provider error the user must act on replaces the root's "failed" (raised once per run).
|
|
1450
|
+
const providerFailure = ended === "failed" && typeof errorType === "string" && providerFailureNeedsUser(errorType)
|
|
1451
|
+
if (providerFailure) this.providerError(rootID, sessionID, error ?? errorTypeLabel(errorType) ?? "Model provider error")
|
|
1452
|
+
if (type === "session.status") {
|
|
1453
|
+
const retry = this.status(rootID)?.find((s) => s.id === sessionID)?.retry
|
|
1454
|
+
if (retry && providerRetryNeedsUser(retry.errorType)) this.providerError(rootID, sessionID, retry.message ?? errorTypeLabel(retry.errorType)!)
|
|
1455
|
+
}
|
|
1441
1456
|
if (ended && root) {
|
|
1442
1457
|
this.doneWatch.rootEnded(rootID, ended, wasRunning)
|
|
1443
1458
|
if (ended === "succeeded" && wasRunning && !this.active.has(rootID)) this.alert("turn", rootID)
|
|
1444
|
-
|
|
1459
|
+
// Devices with providerError on got that instead; the rest still hear about the failure.
|
|
1460
|
+
if (ended === "failed") this.alert("failed", rootID, { ...(error ? { text: error } : {}), ...(providerFailure ? { unlessPref: "providerError" as const } : {}) })
|
|
1445
1461
|
const stopped = ended === "interrupted" ? STOP_REASONS[String(data.reason)] : undefined
|
|
1446
1462
|
if (stopped) this.alert("stopped", rootID, { text: stopped })
|
|
1447
1463
|
return
|
|
@@ -1456,7 +1472,10 @@ export class Registry {
|
|
|
1456
1472
|
}
|
|
1457
1473
|
if (type === "session.status" && root && !before?.retry) {
|
|
1458
1474
|
const retry = this.status(rootID)?.find((s) => s.id === rootID)?.retry
|
|
1459
|
-
if (retry)
|
|
1475
|
+
if (retry) {
|
|
1476
|
+
const why = [retry.attempt !== undefined ? `Attempt ${retry.attempt}` : "", errorTypeLabel(retry.errorType) ?? ""].filter(Boolean).join(" · ")
|
|
1477
|
+
this.alert("retry", rootID, { text: [why, retry.message ?? ""].filter(Boolean).join(": ") || "Retrying" })
|
|
1478
|
+
}
|
|
1460
1479
|
return
|
|
1461
1480
|
}
|
|
1462
1481
|
if (type === "shell.exited" || type === "session.shell.ended" || type === "pty.exited") {
|
|
@@ -1469,6 +1488,15 @@ export class Registry {
|
|
|
1469
1488
|
}
|
|
1470
1489
|
}
|
|
1471
1490
|
|
|
1491
|
+
/** A model provider error of `sessionID` (root or subagent) needs the user: "<session title>: <message>", once per run. */
|
|
1492
|
+
private providerError(rootID: string, sessionID: string, message: string): void {
|
|
1493
|
+
if (this.providerAlerted.has(sessionID)) return
|
|
1494
|
+
this.providerAlerted.add(sessionID)
|
|
1495
|
+
if (this.providerAlerted.size > 1000) this.providerAlerted.delete(this.providerAlerted.values().next().value!)
|
|
1496
|
+
const title = this.status(rootID)?.find((s) => s.id === sessionID)?.title?.trim() || "Session"
|
|
1497
|
+
this.alert("providerError", rootID, { text: `${title}: ${message}`, key: sessionID })
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1472
1500
|
/** Fires when a root's tree stayed settled after its run (DoneWatch). */
|
|
1473
1501
|
private settledTree(rootID: string): boolean {
|
|
1474
1502
|
const board = this.boards.get(rootID)
|
|
@@ -1938,7 +1966,7 @@ export class Registry {
|
|
|
1938
1966
|
* Raises a notification for the root of `sessionID`'s tree. Only hand-offs, questions and permissions reach a
|
|
1939
1967
|
* session some device shows (spoken replies check that themselves: the watching device speaks them).
|
|
1940
1968
|
*/
|
|
1941
|
-
private alert(kind: NotificationKind, sessionID: string, fields: { text?: string; requestID?: string; subject?: string; key?: string } = {}): void {
|
|
1969
|
+
private alert(kind: NotificationKind, sessionID: string, fields: { text?: string; requestID?: string; subject?: string; key?: string; unlessPref?: NotificationKind } = {}): void {
|
|
1942
1970
|
const entry = [...this.boards].find(([, board]) => board.get(sessionID))
|
|
1943
1971
|
if (!entry) return
|
|
1944
1972
|
const [rootID, board] = entry
|
|
@@ -1951,6 +1979,7 @@ export class Registry {
|
|
|
1951
1979
|
...(fields.text !== undefined ? { text: fields.text } : {}),
|
|
1952
1980
|
...(fields.requestID !== undefined ? { requestID: fields.requestID } : {}),
|
|
1953
1981
|
...(fields.key !== undefined ? { key: fields.key } : {}),
|
|
1982
|
+
...(fields.unlessPref !== undefined ? { unlessPref: fields.unlessPref } : {}),
|
|
1954
1983
|
}
|
|
1955
1984
|
const owner = this.sessionTenant(rootID)
|
|
1956
1985
|
for (const listener of this.alertListeners) listener(owner ? { ...alert, owner } : alert)
|
package/src/central/store.ts
CHANGED
|
@@ -58,6 +58,9 @@ export interface ViewPrefs {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
export const VIEW_PREF_KEYS = ["hiddenSessions", "hiddenWorkspaces", "pinnedSessions", "pinnedWorkspaces"] as const
|
|
61
|
+
/** Archived sessions accumulate for longer than the other view-preference lists. */
|
|
62
|
+
export const VIEW_PREF_MAX_ARCHIVED = 5000
|
|
63
|
+
export const VIEW_PREF_MAX_PINNED = 500
|
|
61
64
|
|
|
62
65
|
export interface AuditEntry {
|
|
63
66
|
at: number
|
|
@@ -118,6 +121,11 @@ export interface PushPrefs {
|
|
|
118
121
|
decision: boolean
|
|
119
122
|
/** Agent messaging needs you: a message to @me, an ask left unanswered after every re-wake, or a message loop. */
|
|
120
123
|
message: boolean
|
|
124
|
+
/**
|
|
125
|
+
* A model provider error needs you: a quota, free-tier, budget, auth or no-route failure of any session in the tree
|
|
126
|
+
* (once per run), or a retry for a quota, free-tier or auth error. Replaces the root's "failed" for that failure.
|
|
127
|
+
*/
|
|
128
|
+
providerError: boolean
|
|
121
129
|
}
|
|
122
130
|
|
|
123
131
|
export const PUSH_PREF_KEYS = [
|
|
@@ -138,6 +146,7 @@ export const PUSH_PREF_KEYS = [
|
|
|
138
146
|
"activity",
|
|
139
147
|
"decision",
|
|
140
148
|
"message",
|
|
149
|
+
"providerError",
|
|
141
150
|
] as const satisfies readonly (keyof PushPrefs)[]
|
|
142
151
|
|
|
143
152
|
/** Defaults: anything that needs you, all done, failed, hand-offs and spoken replies; the finer kinds are opt-in. */
|
|
@@ -160,6 +169,7 @@ export const DEFAULT_PUSH_PREFS: Readonly<PushPrefs> = Object.freeze({
|
|
|
160
169
|
activity: false,
|
|
161
170
|
decision: true,
|
|
162
171
|
message: true,
|
|
172
|
+
providerError: true,
|
|
163
173
|
})
|
|
164
174
|
|
|
165
175
|
/** Default and allowed range of a device's activity update interval, in minutes. */
|
|
@@ -911,6 +921,56 @@ export class Store {
|
|
|
911
921
|
})
|
|
912
922
|
}
|
|
913
923
|
|
|
924
|
+
/** Changes one archived session atomically; undefined means the bounded archive list is full. */
|
|
925
|
+
setSessionArchived(id: string, sessionID: string, archived: boolean): Promise<{ changed: boolean } | undefined> {
|
|
926
|
+
return this.mutate((tx) => {
|
|
927
|
+
const user = this.user(id)
|
|
928
|
+
if (!user) return undefined
|
|
929
|
+
const next = this.viewPrefs(id)
|
|
930
|
+
const listed = next.hiddenSessions.includes(sessionID)
|
|
931
|
+
const changed = archived !== listed
|
|
932
|
+
if (!changed) return { changed }
|
|
933
|
+
if (archived && next.hiddenSessions.length >= VIEW_PREF_MAX_ARCHIVED) return undefined
|
|
934
|
+
next.hiddenSessions = archived ? [...next.hiddenSessions, sessionID] : next.hiddenSessions.filter((id) => id !== sessionID)
|
|
935
|
+
user.viewPrefs = next
|
|
936
|
+
tx.touch("users", id)
|
|
937
|
+
return { changed }
|
|
938
|
+
})
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/** Changes one pinned session atomically while preserving every other preference list. */
|
|
942
|
+
setSessionPinned(id: string, sessionID: string, pinned: boolean): Promise<{ changed: boolean } | undefined> {
|
|
943
|
+
return this.mutate((tx) => {
|
|
944
|
+
const user = this.user(id)
|
|
945
|
+
if (!user) return undefined
|
|
946
|
+
const next = this.viewPrefs(id)
|
|
947
|
+
const listed = next.pinnedSessions.includes(sessionID)
|
|
948
|
+
const changed = pinned !== listed
|
|
949
|
+
if (!changed) return { changed }
|
|
950
|
+
if (pinned && next.pinnedSessions.length >= VIEW_PREF_MAX_PINNED) return undefined
|
|
951
|
+
next.pinnedSessions = pinned ? [...next.pinnedSessions, sessionID] : next.pinnedSessions.filter((id) => id !== sessionID)
|
|
952
|
+
user.viewPrefs = next
|
|
953
|
+
tx.touch("users", id)
|
|
954
|
+
return { changed }
|
|
955
|
+
})
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/** Archives only while still unpinned, atomically against concurrent preference writes. */
|
|
959
|
+
archiveSessionIfUnpinned(id: string, sessionID: string): Promise<"changed" | "pinned" | "archived" | "full" | "not_found"> {
|
|
960
|
+
return this.mutate((tx) => {
|
|
961
|
+
const user = this.user(id)
|
|
962
|
+
if (!user) return "not_found"
|
|
963
|
+
const next = this.viewPrefs(id)
|
|
964
|
+
if (next.hiddenSessions.includes(sessionID)) return "archived"
|
|
965
|
+
if (next.pinnedSessions.includes(sessionID)) return "pinned"
|
|
966
|
+
if (next.hiddenSessions.length >= VIEW_PREF_MAX_ARCHIVED) return "full"
|
|
967
|
+
next.hiddenSessions = [...next.hiddenSessions, sessionID]
|
|
968
|
+
user.viewPrefs = next
|
|
969
|
+
tx.touch("users", id)
|
|
970
|
+
return "changed"
|
|
971
|
+
})
|
|
972
|
+
}
|
|
973
|
+
|
|
914
974
|
// ---------- per-user, per-session model preferences ----------
|
|
915
975
|
|
|
916
976
|
modelPrefs(id: string, sessionID: string): SessionModelPrefs {
|