@huynle/hub 0.7.4 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +430 -1
- package/Dockerfile +7 -2
- package/README.md +230 -32
- package/package.json +5 -3
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/daemon.ts +8 -1
- package/packages/claude-code/src/launcher.ts +29 -9
- package/packages/claude-code/src/paths.ts +3 -3
- package/packages/claude-code/src/profile.ts +173 -0
- package/packages/claude-code/src/sessions.ts +22 -4
- package/packages/cli/dist/hub-cli.js +6871 -3460
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
- package/packages/cli/src/artifacts-mcp.ts +160 -0
- package/packages/cli/src/bootstrap/adapters/node-services/render.ts +2 -2
- package/packages/cli/src/bootstrap/discovery/host.ts +15 -1
- package/packages/cli/src/bootstrap/discovery/probe.ts +1 -1
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +21 -9
- package/packages/cli/src/bootstrap/enrollment/policy.ts +138 -12
- package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
- package/packages/cli/src/cli.ts +96 -14
- package/packages/cli/src/format.ts +48 -10
- package/packages/cli/src/http.ts +2 -0
- package/packages/cli/src/intents.ts +23 -0
- package/packages/cli/src/launches.ts +9 -3
- package/packages/cli/src/login.ts +20 -6
- package/packages/cli/src/mcp.ts +385 -10
- package/packages/cli/src/messaging.ts +7 -3
- package/packages/cli/src/profiles-mcp.ts +301 -0
- package/packages/cli/src/setup/commands.ts +23 -4
- package/packages/cli/src/setup/runtime.ts +3 -2
- package/packages/cli/src/spoke-services.ts +1 -1
- package/packages/cli/src/team-intake-mcp.ts +158 -0
- package/packages/cli/src/teams-mcp.ts +460 -0
- package/packages/cli/src/watchlist.ts +135 -0
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/paths.ts +3 -3
- package/packages/codex/src/profile.ts +163 -0
- package/packages/codex/src/sessions.ts +12 -0
- package/packages/codex/src/spoke.ts +19 -1
- package/packages/node/package.json +1 -1
- package/packages/node/src/config.ts +104 -2
- package/packages/node/src/opencode-warm.ts +344 -0
- package/packages/node/src/run.ts +28 -1
- package/packages/preset/package.json +1 -1
- package/packages/preset/src/profile.ts +34 -0
- package/packages/preset/src/spoke.ts +4 -0
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/artifacts.ts +142 -0
- package/packages/protocol/src/concierge.ts +366 -0
- package/packages/protocol/src/contracts.ts +104 -0
- package/packages/protocol/src/graph.ts +70 -0
- package/packages/protocol/src/hub-workspace.ts +201 -0
- package/packages/protocol/src/index.ts +175 -6
- package/packages/protocol/src/launch.ts +36 -0
- package/packages/protocol/src/profiles.ts +301 -0
- package/packages/protocol/src/teams.ts +204 -0
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/address.ts +50 -2
- package/packages/sdk/src/artifact-jobs.ts +194 -0
- package/packages/sdk/src/artifacts.ts +187 -0
- package/packages/sdk/src/client.ts +226 -5
- package/packages/sdk/src/concierge.ts +88 -0
- package/packages/sdk/src/errors.ts +4 -1
- package/packages/sdk/src/index.ts +6 -0
- package/packages/sdk/src/team-intake.ts +122 -0
- package/packages/sdk/src/teams.ts +346 -0
- package/packages/sdk/src/types.ts +568 -1
- package/packages/sdk/src/wake.ts +20 -0
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/index.ts +1 -0
- package/packages/spoke-sdk/src/profile-apply.ts +245 -0
- package/src/central/actions.ts +7 -0
- package/src/central/agent/acting-routes.ts +2 -1
- package/src/central/agent/acting-switch.ts +2 -1
- package/src/central/agent/acting.ts +218 -10
- package/src/central/agent/app-tokens.ts +3 -2
- package/src/central/agent/audit.ts +18 -1
- package/src/central/agent/body.ts +163 -0
- package/src/central/agent/decisions.ts +125 -2
- package/src/central/agent/delivery.ts +194 -40
- package/src/central/agent/directory-routes.ts +37 -7
- package/src/central/agent/directory.ts +70 -14
- package/src/central/agent/errors.ts +7 -0
- package/src/central/agent/escalation.ts +10 -2
- package/src/central/agent/events-routes.ts +21 -4
- package/src/central/agent/events.ts +18 -4
- package/src/central/agent/form-check.ts +85 -0
- package/src/central/agent/grant-requests.ts +2 -1
- package/src/central/agent/grant-routes.ts +4 -3
- package/src/central/agent/grants.ts +29 -2
- package/src/central/agent/idempotency.ts +133 -0
- package/src/central/agent/intent-routes.ts +116 -0
- package/src/central/agent/intents.ts +505 -0
- package/src/central/agent/interaction-graph-routes.ts +212 -0
- package/src/central/agent/interaction-graph.ts +865 -0
- package/src/central/agent/launch-engine.ts +189 -32
- package/src/central/agent/launch-routes.ts +26 -10
- package/src/central/agent/launch-spec.ts +40 -3
- package/src/central/agent/launch-store.ts +55 -2
- package/src/central/agent/message-limits.ts +89 -11
- package/src/central/agent/message-routes.ts +10 -4
- package/src/central/agent/messages.ts +28 -2
- package/src/central/agent/names.ts +12 -2
- package/src/central/agent/placement.ts +93 -18
- package/src/central/agent/read-routes.ts +89 -19
- package/src/central/agent/routes.ts +178 -5
- package/src/central/agent/webhook-routes.ts +2 -1
- package/src/central/agent/webhooks.ts +17 -2
- package/src/central/agent-data.ts +4 -0
- package/src/central/artifacts/access.ts +44 -0
- package/src/central/artifacts/blob-store.ts +113 -0
- package/src/central/artifacts/blob-support.ts +98 -0
- package/src/central/artifacts/events.ts +27 -0
- package/src/central/artifacts/host-caps.ts +162 -0
- package/src/central/artifacts/host.ts +315 -0
- package/src/central/artifacts/idempotency.ts +109 -0
- package/src/central/artifacts/jobs-routes.ts +776 -0
- package/src/central/artifacts/jobs.ts +344 -0
- package/src/central/artifacts/maintenance.ts +103 -0
- package/src/central/artifacts/routes.ts +360 -0
- package/src/central/artifacts/store.ts +776 -0
- package/src/central/artifacts/workspaces.ts +31 -0
- package/src/central/backend/file-blobs.ts +377 -0
- package/src/central/backend/index.ts +12 -3
- package/src/central/backend/memory-blobs.ts +116 -0
- package/src/central/backend/postgres-blobs.ts +513 -0
- package/src/central/backend/postgres-migrate.ts +33 -0
- package/src/central/backend/postgres-state.ts +19 -0
- package/src/central/backend/types.ts +8 -0
- package/src/central/concierge/activity.ts +142 -0
- package/src/central/concierge/arm.ts +63 -0
- package/src/central/concierge/board-render.ts +190 -0
- package/src/central/concierge/board-results.ts +169 -0
- package/src/central/concierge/board-routes.ts +350 -0
- package/src/central/concierge/board.ts +249 -0
- package/src/central/concierge/context.ts +221 -0
- package/src/central/concierge/entitlements.ts +13 -0
- package/src/central/concierge/guard.ts +120 -0
- package/src/central/concierge/inbound.ts +86 -0
- package/src/central/concierge/index.ts +84 -0
- package/src/central/concierge/notes.ts +41 -0
- package/src/central/concierge/notifier.ts +986 -0
- package/src/central/concierge/prompt.ts +131 -0
- package/src/central/concierge/proposals.ts +268 -0
- package/src/central/concierge/push-remap.ts +49 -0
- package/src/central/concierge/routes.ts +122 -0
- package/src/central/concierge/schedule.ts +113 -0
- package/src/central/concierge/service.ts +243 -0
- package/src/central/concierge/session-routes.ts +125 -0
- package/src/central/concierge/settings.ts +217 -0
- package/src/central/concierge/supervisor.ts +767 -0
- package/src/central/concierge/tiers.ts +260 -0
- package/src/central/concierge/watchlist-routes.ts +279 -0
- package/src/central/concierge/watchlist.ts +273 -0
- package/src/central/config.ts +28 -6
- package/src/central/http.ts +660 -54
- package/src/central/main.ts +1 -1
- package/src/central/mcp-http.ts +10 -21
- package/src/central/profiles/apply.ts +333 -0
- package/src/central/profiles/content.ts +174 -0
- package/src/central/profiles/events.ts +40 -0
- package/src/central/profiles/idempotency.ts +14 -0
- package/src/central/profiles/import.ts +139 -0
- package/src/central/profiles/routes.ts +356 -0
- package/src/central/profiles/store.ts +597 -0
- package/src/central/profiles/usage-ports.ts +120 -0
- package/src/central/profiles/usage.ts +364 -0
- package/src/central/push.ts +7 -0
- package/src/central/registry.ts +69 -7
- package/src/central/store.ts +4 -0
- package/src/central/teams/collections.ts +34 -0
- package/src/central/teams/discovery.ts +346 -0
- package/src/central/teams/draft-edits.ts +46 -0
- package/src/central/teams/events.ts +46 -0
- package/src/central/teams/intake-routes.ts +100 -0
- package/src/central/teams/intake.ts +786 -0
- package/src/central/teams/policy.ts +231 -0
- package/src/central/teams/routes.ts +516 -0
- package/src/central/teams/seats.ts +272 -0
- package/src/central/teams/staffing.ts +910 -0
- package/src/central/teams/store.ts +667 -0
- package/src/central/teams/validation.ts +256 -0
- package/src/central/workspaces/board.ts +408 -0
- package/src/central/workspaces/checkpoints.ts +316 -0
- package/src/central/workspaces/coordinator.ts +841 -0
- package/src/central/workspaces/events.ts +85 -0
- package/src/central/workspaces/lifecycle.ts +93 -0
- package/src/central/workspaces/master.ts +490 -0
- package/src/central/workspaces/members.ts +632 -0
- package/src/central/workspaces/routes.ts +354 -0
- package/src/central/workspaces/sidechats-routes.ts +410 -0
- package/src/central/workspaces/sidechats.ts +482 -0
- package/src/central/workspaces/store.ts +571 -0
- package/src/central/workspaces/supervision.ts +355 -0
- package/src/index.ts +126 -23
- package/src/migrate.ts +109 -3
- package/src/spoke/concierge-agent.ts +33 -0
- package/src/spoke/deliver.ts +22 -4
- package/src/spoke/env-inject.ts +8 -1
- package/src/spoke/fork-revert.ts +142 -0
- package/src/spoke/hub-rpc.ts +41 -5
- package/src/spoke/hub-tool.ts +404 -25
- package/src/spoke/launchers.ts +6 -4
- package/src/spoke/listing.ts +65 -10
- package/src/spoke/localhub.ts +14 -4
- package/src/spoke/locations.ts +40 -0
- package/src/spoke/profile-apply.ts +298 -0
- package/src/spoke/session-context.ts +256 -0
- package/src/spoke/spoke.ts +330 -23
- package/src/spoke/timeline.ts +6 -1
- package/src/spoke/tracker.ts +23 -1
- package/src/spoke/who-teams.ts +81 -0
- package/src/tui-messaging.ts +32 -18
- package/web/dist/assets/ArtifactPage-mNzjlP_U.js +1 -0
- package/web/dist/assets/ArtifactPane-CPQcuX7c.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
- package/web/dist/assets/ArtifactWorkbench-NoFC5Sb2.js +6 -0
- package/web/dist/assets/Library-DYsFj_5c.css +1 -0
- package/web/dist/assets/Library-LXHsP5Fw.js +2 -0
- package/web/dist/assets/ProfilesDestination-DhVVcAep.js +1 -0
- package/web/dist/assets/TeamsPage-DBrO5hoL.js +1 -0
- package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
- package/web/dist/assets/Watchtower-DxaJYQp_.js +2 -0
- package/web/dist/assets/index-BabrNDB3.css +1 -0
- package/web/dist/assets/index-lIOfSYck.js +34 -0
- package/web/dist/assets/live-BDq0AABg.js +5 -0
- package/web/dist/assets/live-BUofsQpz.css +1 -0
- package/web/dist/assets/pane-layout-DWXfDU-1.js +1 -0
- package/web/dist/assets/plural-Bspvoqfq.js +1 -0
- package/web/dist/assets/renderer2d-DAr4IHM8.js +1 -0
- package/web/dist/assets/renderer3d-DWZj595u.js +5393 -0
- package/web/dist/assets/safe-area-JUb6OeI3.js +1 -0
- package/web/dist/assets/source-CDU04kYw.js +1 -0
- package/web/dist/assets/ui-Dh1-78Cl.js +3 -0
- package/web/dist/index.html +5 -2
- package/web/dist/sw.js +22 -2
- package/web/dist/assets/index-Dm8OmkaO.css +0 -1
- package/web/dist/assets/index-FUJbj_Du.js +0 -20
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// The hub's sources for profile usage (RP-08; usage.ts): one bounded read per source, never one per session.
|
|
2
|
+
// - sessions: one directory read (Directory.entries) for every pinned session; the harness only for a session a
|
|
3
|
+
// connection of the same tenant owns now (registry.owner + harnessOf, tenant-checked).
|
|
4
|
+
// - team roles: the user's active teams (one paged list, ≤ teamsPerUser) and, when a role names a profile, one
|
|
5
|
+
// scan of the seats (TeamSeatStore.currentCounts).
|
|
6
|
+
// - launches: the user's retained launches (one list; the launch store prunes ended ones).
|
|
7
|
+
// - workspaces: one scan of the memberships (MembershipStore.membershipsOfMany) and one paged workspace list for
|
|
8
|
+
// the titles, only when a pinned session is a member somewhere.
|
|
9
|
+
import { LIST_LIMIT_MAX } from "../../protocol"
|
|
10
|
+
import type { Directory } from "../agent/directory"
|
|
11
|
+
import { AgentError } from "../agent/errors"
|
|
12
|
+
import type { RouteLaunches } from "../agent/launch-routes"
|
|
13
|
+
import type { ReadRegistry } from "../agent/read-routes"
|
|
14
|
+
import type { TeamSeatStore } from "../teams/seats"
|
|
15
|
+
import type { Team, TeamStore } from "../teams/store"
|
|
16
|
+
import type { MembershipStore } from "../workspaces/members"
|
|
17
|
+
import type { WorkspaceStore } from "../workspaces/store"
|
|
18
|
+
import type { UsagePorts, UsageSessionInfo, UsageTeamRoleRef } from "./usage"
|
|
19
|
+
|
|
20
|
+
export interface UsagePortDeps {
|
|
21
|
+
readonly directory?: Pick<Directory, "entries">
|
|
22
|
+
readonly registry?: Pick<ReadRegistry, "owner" | "sessionTenant" | "harnessOf">
|
|
23
|
+
readonly teams?: { readonly teams: Pick<TeamStore, "list">; readonly seats: Pick<TeamSeatStore, "currentCounts"> }
|
|
24
|
+
readonly launches?: Pick<RouteLaunches, "list">
|
|
25
|
+
readonly workspaces?: { readonly workspaces: Pick<WorkspaceStore, "list">; readonly members: Pick<MembershipStore, "membershipsOfMany"> }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function usagePorts(deps: UsagePortDeps): UsagePorts {
|
|
29
|
+
const { directory, registry, teams, launches, workspaces } = deps
|
|
30
|
+
return {
|
|
31
|
+
...(directory
|
|
32
|
+
? {
|
|
33
|
+
sessions(userID: string, sessionIDs: readonly string[]) {
|
|
34
|
+
let entries
|
|
35
|
+
try {
|
|
36
|
+
entries = directory.entries(userID, sessionIDs)
|
|
37
|
+
} catch (cause) {
|
|
38
|
+
if (cause instanceof AgentError) return new Map()
|
|
39
|
+
throw cause
|
|
40
|
+
}
|
|
41
|
+
const out = new Map<string, UsageSessionInfo>()
|
|
42
|
+
for (const [id, e] of entries) {
|
|
43
|
+
const conn = e.online && registry && registry.sessionTenant(id) === userID ? registry.owner(id) : undefined
|
|
44
|
+
const harness = conn ? registry?.harnessOf?.(conn) : undefined
|
|
45
|
+
out.set(id, {
|
|
46
|
+
...(e.name !== undefined ? { name: e.name } : {}),
|
|
47
|
+
...(e.title !== undefined ? { title: e.title } : {}),
|
|
48
|
+
...(e.machineID !== undefined ? { machineID: e.machineID } : {}),
|
|
49
|
+
...(harness !== undefined ? { harness } : {}),
|
|
50
|
+
...(e.project !== undefined ? { project: e.project } : {}),
|
|
51
|
+
online: e.online,
|
|
52
|
+
...(e.lastSeen !== undefined ? { lastSeen: e.lastSeen } : {}),
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
return out
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
: {}),
|
|
59
|
+
...(teams
|
|
60
|
+
? {
|
|
61
|
+
async teamRoles(userID: string) {
|
|
62
|
+
const active: Team[] = []
|
|
63
|
+
let cursor: string | undefined
|
|
64
|
+
do {
|
|
65
|
+
const page = await teams.teams.list(userID, { state: "active", limit: LIST_LIMIT_MAX, ...(cursor ? { cursor } : {}) })
|
|
66
|
+
active.push(...page.items)
|
|
67
|
+
cursor = page.cursor
|
|
68
|
+
} while (cursor)
|
|
69
|
+
const named = active.flatMap((t) => t.roles.filter((r) => r.profile).map((r) => ({ team: t, role: r })))
|
|
70
|
+
if (!named.length) return []
|
|
71
|
+
const counts = await teams.seats.currentCounts(userID)
|
|
72
|
+
return named.map(({ team, role }): UsageTeamRoleRef => ({
|
|
73
|
+
teamID: team.id,
|
|
74
|
+
teamName: team.name,
|
|
75
|
+
teamTitle: team.title,
|
|
76
|
+
role: role.name,
|
|
77
|
+
...(role.label !== undefined ? { label: role.label } : {}),
|
|
78
|
+
profileID: role.profile!.profileID,
|
|
79
|
+
...(role.profile!.version !== undefined ? { version: role.profile!.version } : {}),
|
|
80
|
+
staffed: counts.get(team.id)?.get(role.name) ?? 0,
|
|
81
|
+
}))
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
: {}),
|
|
85
|
+
...(launches
|
|
86
|
+
? {
|
|
87
|
+
async launches(userID: string) {
|
|
88
|
+
return (await launches.list(userID)).map((l) => ({
|
|
89
|
+
id: l.id,
|
|
90
|
+
state: l.state,
|
|
91
|
+
...(l.outcome !== undefined ? { outcome: l.outcome } : {}),
|
|
92
|
+
...(l.by !== undefined ? { by: l.by } : {}),
|
|
93
|
+
...(l.sessionID !== undefined ? { sessionID: l.sessionID } : {}),
|
|
94
|
+
created: l.created,
|
|
95
|
+
at: l.at,
|
|
96
|
+
...(l.spec.agentProfile ? { agentProfile: { profileID: l.spec.agentProfile.profileID, version: l.spec.agentProfile.version } } : {}),
|
|
97
|
+
}))
|
|
98
|
+
},
|
|
99
|
+
}
|
|
100
|
+
: {}),
|
|
101
|
+
...(workspaces
|
|
102
|
+
? {
|
|
103
|
+
async workspaces(userID: string, sessionIDs: readonly string[]) {
|
|
104
|
+
const memberships = await workspaces.members.membershipsOfMany(userID, sessionIDs)
|
|
105
|
+
if (!memberships.size) return new Map()
|
|
106
|
+
const titles = new Map<string, string>()
|
|
107
|
+
let cursor: string | undefined
|
|
108
|
+
do {
|
|
109
|
+
const page = await workspaces.workspaces.list(userID, { limit: LIST_LIMIT_MAX, ...(cursor ? { cursor } : {}) })
|
|
110
|
+
for (const w of page.items) titles.set(w.id, w.title)
|
|
111
|
+
cursor = page.cursor
|
|
112
|
+
} while (cursor)
|
|
113
|
+
return new Map(
|
|
114
|
+
[...memberships].map(([sessionID, ids]) => [sessionID, ids.map((workspaceID) => ({ workspaceID, ...(titles.has(workspaceID) ? { title: titles.get(workspaceID)! } : {}) }))] as const),
|
|
115
|
+
)
|
|
116
|
+
},
|
|
117
|
+
}
|
|
118
|
+
: {}),
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
// Where a profile is used (RP-08): GET /api/agent/profiles/:id/usage and the per-profile counts of
|
|
2
|
+
// GET /api/agent/profiles?include=usage. Read-only; it changes nothing and confers nothing.
|
|
3
|
+
//
|
|
4
|
+
// Sources (one bounded pass each, never one read per session — no N+1):
|
|
5
|
+
// - pins: ONE scan of the user's `profile-assignments` collection (ProfileStore.assignmentsScan), capped at
|
|
6
|
+
// `limits.assignments`. No reverse index: assignments are keyed by session and bounded by the user's sessions
|
|
7
|
+
// (the directory keeps at most SESSION_LIMIT), so a capped scan is cheaper than keeping a second index consistent
|
|
8
|
+
// with every pin, unpin and retention write. Past the cap the answer says `truncated: ["sessions"]`.
|
|
9
|
+
// - sessions: one directory read for all pinned session IDs (name, title, machine, project, presence), with the
|
|
10
|
+
// harness of the session's owning connection when it is online (ports.sessions).
|
|
11
|
+
// - team roles: the published roles of the user's active teams that name the profile, with their current staffed
|
|
12
|
+
// seat counts (ports.teamRoles: one team list and one seats scan).
|
|
13
|
+
// - launches: the user's retained launches whose spec named the profile (ports.launches: one list).
|
|
14
|
+
// - workspaces: the current workspace memberships of the pinned sessions (ports.workspaces: one members scan).
|
|
15
|
+
//
|
|
16
|
+
// Authorization: the route needs profiles:read (users always; session tokens never). Each category then mirrors the
|
|
17
|
+
// existing read rule of what it shows, and a category the caller may not read is left out entirely (no counts
|
|
18
|
+
// either) and named in `hidden`:
|
|
19
|
+
// sessions apps need sessions:read (session IDs, presence, machine, harness, project)
|
|
20
|
+
// teams apps need teams:read or teams:manage (team, role; no session IDs)
|
|
21
|
+
// seat occupancy (`staffed`, `staffedSeats`) also needs sessions:read, like the team seat, roster,
|
|
22
|
+
// status and staffing routes; without it the roles are shown without those fields
|
|
23
|
+
// launches apps need sessions:launch, and see only the launches they started (launch.by), like GET /launches;
|
|
24
|
+
// a launch's session ID also needs sessions:read
|
|
25
|
+
// workspaces apps need workspaces:read and sessions:read (derived from the sessions' memberships)
|
|
26
|
+
// Another user's or an unknown profile is the same 404 (the profile is looked up under the caller's user first).
|
|
27
|
+
import { LIST_LIMIT_DEFAULT, LIST_LIMIT_MAX, type ProfileAssignment } from "../../protocol"
|
|
28
|
+
import type { AgentActor } from "../agent/actor"
|
|
29
|
+
import { AgentError } from "../agent/errors"
|
|
30
|
+
import type { ProfileStore } from "./store"
|
|
31
|
+
|
|
32
|
+
/** One pinned session as the directory and registry know it (absent: not in the directory any more). */
|
|
33
|
+
export interface UsageSessionInfo {
|
|
34
|
+
readonly name?: string
|
|
35
|
+
readonly title?: string
|
|
36
|
+
readonly machineID?: string
|
|
37
|
+
/** The harness of the connection that owns the session (online sessions only). */
|
|
38
|
+
readonly harness?: string
|
|
39
|
+
readonly project?: string
|
|
40
|
+
readonly online: boolean
|
|
41
|
+
readonly lastSeen?: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A published team role that names a profile. */
|
|
45
|
+
export interface UsageTeamRoleRef {
|
|
46
|
+
readonly teamID: string
|
|
47
|
+
readonly teamName: string
|
|
48
|
+
readonly teamTitle: string
|
|
49
|
+
readonly role: string
|
|
50
|
+
readonly label?: string
|
|
51
|
+
readonly profileID: string
|
|
52
|
+
/** The role's requested version; absent: the latest at staffing time. */
|
|
53
|
+
readonly version?: number
|
|
54
|
+
/** Current seats in that role. */
|
|
55
|
+
readonly staffed: number
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A retained launch, as far as usage needs it. */
|
|
59
|
+
export interface UsageLaunch {
|
|
60
|
+
readonly id: string
|
|
61
|
+
readonly state: string
|
|
62
|
+
readonly outcome?: string
|
|
63
|
+
/** Who launched it (`user`, `app:<token>`, a session ID): the launch read rule. */
|
|
64
|
+
readonly by?: string
|
|
65
|
+
readonly sessionID?: string
|
|
66
|
+
readonly created: number
|
|
67
|
+
/** When it entered its state. */
|
|
68
|
+
readonly at: number
|
|
69
|
+
readonly agentProfile?: { readonly profileID: string; readonly version: number }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface UsageLimits {
|
|
73
|
+
/** Pins scanned per request. */
|
|
74
|
+
readonly assignments: number
|
|
75
|
+
/** Team roles, launches and workspaces listed (counts stay exact). */
|
|
76
|
+
readonly teamRoles: number
|
|
77
|
+
readonly launches: number
|
|
78
|
+
readonly workspaces: number
|
|
79
|
+
/** Distinct team IDs per profile in the list counts. */
|
|
80
|
+
readonly teamsPerCount: number
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const USAGE_LIMITS: UsageLimits = { assignments: 10_000, teamRoles: 100, launches: 50, workspaces: 50, teamsPerCount: 16 }
|
|
84
|
+
|
|
85
|
+
type Memberships = ReadonlyMap<string, readonly { readonly workspaceID: string; readonly title?: string }[]>
|
|
86
|
+
|
|
87
|
+
/** The hub's sources; a missing port reads as "nothing uses it there". */
|
|
88
|
+
export interface UsagePorts {
|
|
89
|
+
sessions?(userID: string, sessionIDs: readonly string[]): ReadonlyMap<string, UsageSessionInfo>
|
|
90
|
+
teamRoles?(userID: string): Promise<readonly UsageTeamRoleRef[]>
|
|
91
|
+
launches?(userID: string): Promise<readonly UsageLaunch[]>
|
|
92
|
+
workspaces?(userID: string, sessionIDs: readonly string[]): Promise<Memberships>
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** What one caller may see. */
|
|
96
|
+
export interface UsageView {
|
|
97
|
+
readonly sessions: boolean
|
|
98
|
+
readonly teams: boolean
|
|
99
|
+
/** false: hidden; otherwise every launch, or (apps) only the ones `by` started. */
|
|
100
|
+
readonly launches: false | { readonly by?: string }
|
|
101
|
+
readonly workspaces: boolean
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export type UsageCategory = "sessions" | "teams" | "launches" | "workspaces"
|
|
105
|
+
|
|
106
|
+
/** The caller's view: users see everything; apps by scope (see the header); session tokens never get here. */
|
|
107
|
+
export function usageView(actor: AgentActor): UsageView {
|
|
108
|
+
if (actor.kind === "user") return { sessions: true, teams: true, launches: {}, workspaces: true }
|
|
109
|
+
if (actor.kind === "session") return { sessions: false, teams: false, launches: false, workspaces: false }
|
|
110
|
+
const has = (scope: string) => actor.scopes.includes(scope)
|
|
111
|
+
return {
|
|
112
|
+
sessions: has("sessions:read"),
|
|
113
|
+
teams: has("teams:read") || has("teams:manage"),
|
|
114
|
+
launches: has("sessions:launch") ? { by: `app:${actor.tokenID}` } : false,
|
|
115
|
+
workspaces: has("workspaces:read") && has("sessions:read"),
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Seat occupancy is session information: team roles plus sessions:read (the team seat/roster/status rule). */
|
|
120
|
+
const seatsVisible = (view: UsageView): boolean => view.teams && view.sessions
|
|
121
|
+
|
|
122
|
+
function hiddenOf(view: UsageView, categories: readonly UsageCategory[]): UsageCategory[] {
|
|
123
|
+
const shown: Record<UsageCategory, boolean> = { sessions: view.sessions, teams: view.teams, launches: view.launches !== false, workspaces: view.workspaces }
|
|
124
|
+
return categories.filter((c) => !shown[c])
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface UsageSessionItem {
|
|
128
|
+
readonly sessionID: string
|
|
129
|
+
readonly name?: string
|
|
130
|
+
readonly title?: string
|
|
131
|
+
readonly machineID?: string
|
|
132
|
+
readonly harness?: string
|
|
133
|
+
readonly project?: string
|
|
134
|
+
readonly online: boolean
|
|
135
|
+
readonly lastSeen?: number
|
|
136
|
+
/** Not in the directory any more (deleted); the pin is still stored. */
|
|
137
|
+
readonly known?: false
|
|
138
|
+
/** The requested (pinned) version and the pin's revision (for an explicit upgrade with expectedRevision). */
|
|
139
|
+
readonly version: number
|
|
140
|
+
readonly revision: number
|
|
141
|
+
/** Pinned to a version older than the profile's latest. */
|
|
142
|
+
readonly olderVersion: boolean
|
|
143
|
+
readonly ack?: { readonly status: string; readonly at: number; readonly offline?: true }
|
|
144
|
+
/** What the session last confirmed running. */
|
|
145
|
+
readonly effective?: { readonly version: number; readonly status: string; readonly at: number }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface UsageCounts {
|
|
149
|
+
sessions?: number
|
|
150
|
+
online?: number
|
|
151
|
+
olderVersion?: number
|
|
152
|
+
teamRoles?: number
|
|
153
|
+
staffedSeats?: number
|
|
154
|
+
launches?: number
|
|
155
|
+
liveLaunches?: number
|
|
156
|
+
workspaces?: number
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The list's per-profile counts (no workspaces: that would need the members scan for every page). */
|
|
160
|
+
export interface UsageListCounts extends UsageCounts {
|
|
161
|
+
/** Names (the #team slug, which team routes also accept) of the active teams whose roles name the profile. */
|
|
162
|
+
teams?: string[]
|
|
163
|
+
harnesses?: string[]
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export interface ProfileUsageAnswer {
|
|
167
|
+
readonly profileID: string
|
|
168
|
+
readonly latestVersion: number
|
|
169
|
+
readonly counts: UsageCounts
|
|
170
|
+
readonly sessions?: { readonly items: UsageSessionItem[]; readonly cursor?: string }
|
|
171
|
+
readonly projects?: string[]
|
|
172
|
+
/** `staffed` only when seats are visible (sessions:read). */
|
|
173
|
+
readonly teamRoles?: (Omit<UsageTeamRoleRef, "profileID" | "staffed"> & { staffed?: number })[]
|
|
174
|
+
readonly launches?: { id: string; state: string; outcome?: string; version: number; sessionID?: string; created: number; updated: number }[]
|
|
175
|
+
readonly workspaces?: { workspaceID: string; title?: string; sessions: number }[]
|
|
176
|
+
readonly hidden?: UsageCategory[]
|
|
177
|
+
readonly truncated?: ("sessions" | "teamRoles" | "launches" | "workspaces")[]
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const encodeCursor = (sessionID: string) => Buffer.from(JSON.stringify({ k: sessionID })).toString("base64url")
|
|
181
|
+
function decodeCursor(cursor: string): string {
|
|
182
|
+
try {
|
|
183
|
+
const parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")) as { k?: unknown }
|
|
184
|
+
if (typeof parsed.k === "string") return parsed.k
|
|
185
|
+
} catch {
|
|
186
|
+
// answered below
|
|
187
|
+
}
|
|
188
|
+
throw new AgentError("invalid", "Bad cursor; start again without one")
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function pageLimit(limit: number | undefined): number {
|
|
192
|
+
if (limit === undefined) return LIST_LIMIT_DEFAULT
|
|
193
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > LIST_LIMIT_MAX) throw new AgentError("invalid", `limit must be 1–${LIST_LIMIT_MAX}`)
|
|
194
|
+
return limit
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const LIVE = (l: UsageLaunch) => l.state !== "ended"
|
|
198
|
+
const byNewest = (a: UsageLaunch, b: UsageLaunch) => b.created - a.created || (a.id < b.id ? 1 : a.id > b.id ? -1 : 0)
|
|
199
|
+
|
|
200
|
+
export class ProfileUsage {
|
|
201
|
+
private readonly profiles: Pick<ProfileStore, "get" | "assignmentsScan">
|
|
202
|
+
private readonly ports: UsagePorts
|
|
203
|
+
readonly limits: UsageLimits
|
|
204
|
+
|
|
205
|
+
constructor(options: { readonly profiles: Pick<ProfileStore, "get" | "assignmentsScan">; readonly ports?: UsagePorts; readonly limits?: Partial<UsageLimits> }) {
|
|
206
|
+
this.profiles = options.profiles
|
|
207
|
+
this.ports = options.ports ?? {}
|
|
208
|
+
this.limits = { ...USAGE_LIMITS, ...options.limits }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** One profile's usage for `view`; not_found for an unknown or another user's profile. */
|
|
212
|
+
async usage(userID: string, profileID: string, view: UsageView, page: { readonly limit?: number; readonly cursor?: string } = {}): Promise<ProfileUsageAnswer> {
|
|
213
|
+
const limit = pageLimit(page.limit)
|
|
214
|
+
const after = page.cursor === undefined ? undefined : decodeCursor(page.cursor)
|
|
215
|
+
const profile = await this.profiles.get(userID, profileID)
|
|
216
|
+
if (!profile) throw new AgentError("not_found", `No profile ${profileID}`)
|
|
217
|
+
const counts: UsageCounts = {}
|
|
218
|
+
const truncated: NonNullable<ProfileUsageAnswer["truncated"]> = []
|
|
219
|
+
const answer: { -readonly [K in keyof ProfileUsageAnswer]: ProfileUsageAnswer[K] } = { profileID: profile.id, latestVersion: profile.latestVersion, counts }
|
|
220
|
+
|
|
221
|
+
let pins: ProfileAssignment[] = []
|
|
222
|
+
if (view.sessions || view.workspaces) {
|
|
223
|
+
const scan = await this.profiles.assignmentsScan(userID, this.limits.assignments)
|
|
224
|
+
if (scan.truncated) truncated.push("sessions")
|
|
225
|
+
pins = scan.items.filter((a) => a.profileID === profile.id).sort((a, b) => (a.sessionID < b.sessionID ? -1 : a.sessionID > b.sessionID ? 1 : 0))
|
|
226
|
+
}
|
|
227
|
+
const ids = pins.map((p) => p.sessionID)
|
|
228
|
+
|
|
229
|
+
if (view.sessions) {
|
|
230
|
+
const info = ids.length && this.ports.sessions ? this.ports.sessions(userID, ids) : new Map<string, UsageSessionInfo>()
|
|
231
|
+
const items = pins.map((pin) => sessionItem(pin, info.get(pin.sessionID), profile.latestVersion))
|
|
232
|
+
counts.sessions = items.length
|
|
233
|
+
counts.online = items.filter((s) => s.online).length
|
|
234
|
+
counts.olderVersion = items.filter((s) => s.olderVersion).length
|
|
235
|
+
const rest = after === undefined ? items : items.filter((s) => s.sessionID > after)
|
|
236
|
+
const shown = rest.slice(0, limit)
|
|
237
|
+
answer.sessions = { items: shown, ...(rest.length > limit ? { cursor: encodeCursor(shown.at(-1)!.sessionID) } : {}) }
|
|
238
|
+
answer.projects = [...new Set(items.map((s) => s.project).filter((p): p is string => !!p))].sort()
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (view.teams) {
|
|
242
|
+
const refs = this.ports.teamRoles ? (await this.ports.teamRoles(userID)).filter((r) => r.profileID === profile.id) : []
|
|
243
|
+
const seats = seatsVisible(view)
|
|
244
|
+
counts.teamRoles = refs.length
|
|
245
|
+
if (seats) counts.staffedSeats = refs.reduce((n, r) => n + r.staffed, 0)
|
|
246
|
+
if (refs.length > this.limits.teamRoles) truncated.push("teamRoles")
|
|
247
|
+
answer.teamRoles = refs.slice(0, this.limits.teamRoles).map(({ profileID: _p, staffed, ...role }) => (seats ? { ...role, staffed } : role))
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (view.launches !== false) {
|
|
251
|
+
const by = view.launches.by
|
|
252
|
+
const named = this.ports.launches ? (await this.ports.launches(userID)).filter((l) => l.agentProfile?.profileID === profile.id && (by === undefined || l.by === by)) : []
|
|
253
|
+
counts.launches = named.length
|
|
254
|
+
counts.liveLaunches = named.filter(LIVE).length
|
|
255
|
+
if (named.length > this.limits.launches) truncated.push("launches")
|
|
256
|
+
answer.launches = [...named]
|
|
257
|
+
.sort(byNewest)
|
|
258
|
+
.slice(0, this.limits.launches)
|
|
259
|
+
.map((l) => ({
|
|
260
|
+
id: l.id,
|
|
261
|
+
state: l.state,
|
|
262
|
+
...(l.outcome !== undefined ? { outcome: l.outcome } : {}),
|
|
263
|
+
version: l.agentProfile!.version,
|
|
264
|
+
...(view.sessions && l.sessionID !== undefined ? { sessionID: l.sessionID } : {}),
|
|
265
|
+
created: l.created,
|
|
266
|
+
updated: l.at,
|
|
267
|
+
}))
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (view.workspaces) {
|
|
271
|
+
const memberships = ids.length && this.ports.workspaces ? await this.ports.workspaces(userID, ids) : new Map()
|
|
272
|
+
const found = new Map<string, { workspaceID: string; title?: string; sessions: number }>()
|
|
273
|
+
for (const id of ids) {
|
|
274
|
+
for (const m of memberships.get(id) ?? []) {
|
|
275
|
+
const entry = found.get(m.workspaceID) ?? { workspaceID: m.workspaceID, ...(m.title !== undefined ? { title: m.title } : {}), sessions: 0 }
|
|
276
|
+
entry.sessions++
|
|
277
|
+
found.set(m.workspaceID, entry)
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
const all = [...found.values()].sort((a, b) => b.sessions - a.sessions || (a.workspaceID < b.workspaceID ? -1 : 1))
|
|
281
|
+
counts.workspaces = all.length
|
|
282
|
+
if (all.length > this.limits.workspaces) truncated.push("workspaces")
|
|
283
|
+
answer.workspaces = all.slice(0, this.limits.workspaces)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const hidden = hiddenOf(view, ["sessions", "teams", "launches", "workspaces"])
|
|
287
|
+
if (hidden.length) answer.hidden = hidden
|
|
288
|
+
if (truncated.length) answer.truncated = truncated
|
|
289
|
+
return answer
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Per-profile counts for a page of the list (one pass per source, whatever the page size). */
|
|
293
|
+
async counts(
|
|
294
|
+
userID: string,
|
|
295
|
+
profiles: readonly { readonly id: string; readonly latestVersion: number }[],
|
|
296
|
+
view: UsageView,
|
|
297
|
+
): Promise<{ usage: Record<string, UsageListCounts>; hidden?: UsageCategory[]; truncated?: ["sessions"] }> {
|
|
298
|
+
const usage: Record<string, UsageListCounts> = {}
|
|
299
|
+
const latest = new Map(profiles.map((p) => [p.id, p.latestVersion]))
|
|
300
|
+
for (const p of profiles) usage[p.id] = {}
|
|
301
|
+
let truncated = false
|
|
302
|
+
if (profiles.length && view.sessions) {
|
|
303
|
+
const scan = await this.profiles.assignmentsScan(userID, this.limits.assignments)
|
|
304
|
+
truncated = scan.truncated
|
|
305
|
+
const pins = scan.items.filter((a) => latest.has(a.profileID))
|
|
306
|
+
const info = pins.length && this.ports.sessions ? this.ports.sessions(userID, pins.map((p) => p.sessionID)) : new Map<string, UsageSessionInfo>()
|
|
307
|
+
for (const p of profiles) Object.assign(usage[p.id]!, { sessions: 0, online: 0, olderVersion: 0, harnesses: [] })
|
|
308
|
+
const harnesses = new Map<string, Set<string>>()
|
|
309
|
+
for (const pin of pins) {
|
|
310
|
+
const c = usage[pin.profileID]!
|
|
311
|
+
const s = info.get(pin.sessionID)
|
|
312
|
+
c.sessions!++
|
|
313
|
+
if (s?.online) c.online!++
|
|
314
|
+
if (pin.version < latest.get(pin.profileID)!) c.olderVersion!++
|
|
315
|
+
if (s?.harness) harnesses.set(pin.profileID, (harnesses.get(pin.profileID) ?? new Set()).add(s.harness))
|
|
316
|
+
}
|
|
317
|
+
for (const [id, set] of harnesses) usage[id]!.harnesses = [...set].sort()
|
|
318
|
+
}
|
|
319
|
+
if (profiles.length && view.teams) {
|
|
320
|
+
const refs = this.ports.teamRoles ? await this.ports.teamRoles(userID) : []
|
|
321
|
+
const seats = seatsVisible(view)
|
|
322
|
+
for (const p of profiles) Object.assign(usage[p.id]!, { teamRoles: 0, ...(seats ? { staffedSeats: 0 } : {}), teams: [] })
|
|
323
|
+
for (const ref of refs) {
|
|
324
|
+
const c = usage[ref.profileID]
|
|
325
|
+
if (!c) continue
|
|
326
|
+
c.teamRoles!++
|
|
327
|
+
if (seats) c.staffedSeats! += ref.staffed
|
|
328
|
+
if (!c.teams!.includes(ref.teamName) && c.teams!.length < this.limits.teamsPerCount) c.teams!.push(ref.teamName)
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
if (profiles.length && view.launches !== false) {
|
|
332
|
+
const by = view.launches.by
|
|
333
|
+
const all = this.ports.launches ? await this.ports.launches(userID) : []
|
|
334
|
+
for (const p of profiles) Object.assign(usage[p.id]!, { launches: 0, liveLaunches: 0 })
|
|
335
|
+
for (const l of all) {
|
|
336
|
+
const c = l.agentProfile && usage[l.agentProfile.profileID]
|
|
337
|
+
if (!c || (by !== undefined && l.by !== by)) continue
|
|
338
|
+
c.launches!++
|
|
339
|
+
if (LIVE(l)) c.liveLaunches!++
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
const hidden = hiddenOf(view, ["sessions", "teams", "launches"])
|
|
343
|
+
return { usage, ...(hidden.length ? { hidden } : {}), ...(truncated ? { truncated: ["sessions"] as ["sessions"] } : {}) }
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function sessionItem(pin: ProfileAssignment, info: UsageSessionInfo | undefined, latestVersion: number): UsageSessionItem {
|
|
348
|
+
return {
|
|
349
|
+
sessionID: pin.sessionID,
|
|
350
|
+
...(info?.name !== undefined ? { name: info.name } : {}),
|
|
351
|
+
...(info?.title !== undefined ? { title: info.title } : {}),
|
|
352
|
+
...(info?.machineID !== undefined ? { machineID: info.machineID } : {}),
|
|
353
|
+
...(info?.harness !== undefined ? { harness: info.harness } : {}),
|
|
354
|
+
...(info?.project !== undefined ? { project: info.project } : {}),
|
|
355
|
+
online: info?.online === true,
|
|
356
|
+
...(info?.lastSeen !== undefined ? { lastSeen: info.lastSeen } : {}),
|
|
357
|
+
...(info ? {} : { known: false as const }),
|
|
358
|
+
version: pin.version,
|
|
359
|
+
revision: pin.revision,
|
|
360
|
+
olderVersion: pin.version < latestVersion,
|
|
361
|
+
...(pin.ack ? { ack: { status: pin.ack.status, at: pin.ack.at, ...(pin.ack.offline ? { offline: true as const } : {}) } } : {}),
|
|
362
|
+
...(pin.effective && pin.effective.profileID === pin.profileID ? { effective: { version: pin.effective.version, status: pin.effective.status, at: pin.effective.at } } : {}),
|
|
363
|
+
}
|
|
364
|
+
}
|
package/src/central/push.ts
CHANGED
|
@@ -33,6 +33,7 @@ export const NOTIFICATION_LABELS: Readonly<Record<NotificationKind, string>> = O
|
|
|
33
33
|
decision: "Decision",
|
|
34
34
|
message: "Message",
|
|
35
35
|
providerError: "Model provider error",
|
|
36
|
+
concierge: "Concierge",
|
|
36
37
|
})
|
|
37
38
|
|
|
38
39
|
/** True for a known notification kind (API input). */
|
|
@@ -63,6 +64,8 @@ export interface PushPayload {
|
|
|
63
64
|
readonly test?: true
|
|
64
65
|
/** Per-session kinds (providerError, subagentFailed): the session it is about, so the service worker tags each apart. */
|
|
65
66
|
readonly tagKey?: string
|
|
67
|
+
/** (CON-04) Where a tap opens, when not the session page (kind concierge: /concierge). */
|
|
68
|
+
readonly url?: string
|
|
66
69
|
}
|
|
67
70
|
|
|
68
71
|
const minutes = (clock: string): number => Number(clock.slice(0, 2)) * 60 + Number(clock.slice(3, 5))
|
|
@@ -213,6 +216,9 @@ export function pushPayload(kind: NotificationKind, sessionID: string, sessionTi
|
|
|
213
216
|
return { kind, sessionID, title, body: "A decision waits for you. Tap to answer.", ...(extra.decisionID ? { decisionID: extra.decisionID } : {}) }
|
|
214
217
|
case "message":
|
|
215
218
|
return { kind, sessionID, title, body: "Tap to see it." }
|
|
219
|
+
// The Concierge's session is hidden: the tap opens the Concierge, never its session page; no text.
|
|
220
|
+
case "concierge":
|
|
221
|
+
return { kind, sessionID, title: "Concierge", body: "The Concierge has something for you. Tap to open it.", url: "/concierge" }
|
|
216
222
|
case "permission":
|
|
217
223
|
// Never the request's summary (it may be a command line); the service worker fetches it from the hub.
|
|
218
224
|
return { kind, sessionID, title, body: "Tap to review and answer.", ...(extra.requestID ? { requestID: extra.requestID } : {}) }
|
|
@@ -240,6 +246,7 @@ export function sendOptionsFor(kind: NotificationKind | "test"): { TTL: number;
|
|
|
240
246
|
case "decision":
|
|
241
247
|
case "message":
|
|
242
248
|
case "providerError":
|
|
249
|
+
case "concierge":
|
|
243
250
|
return { TTL: URGENT_TTL_SECONDS, urgency: "high" }
|
|
244
251
|
case "done":
|
|
245
252
|
case "stopped":
|