@huynle/hub 0.7.3 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +229 -0
- package/Dockerfile +7 -2
- package/README.md +153 -24
- package/package.json +5 -3
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/daemon.ts +8 -1
- package/packages/claude-code/src/launcher.ts +29 -9
- package/packages/claude-code/src/profile.ts +173 -0
- package/packages/claude-code/src/sessions.ts +22 -4
- package/packages/cli/dist/hub-cli.js +6474 -3470
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
- package/packages/cli/src/artifacts-mcp.ts +160 -0
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +20 -8
- package/packages/cli/src/bootstrap/enrollment/policy.ts +125 -12
- package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
- package/packages/cli/src/cli.ts +95 -13
- package/packages/cli/src/format.ts +48 -10
- package/packages/cli/src/http.ts +1 -0
- package/packages/cli/src/intents.ts +23 -0
- package/packages/cli/src/login.ts +20 -6
- package/packages/cli/src/mcp.ts +380 -9
- package/packages/cli/src/messaging.ts +3 -1
- package/packages/cli/src/setup/commands.ts +3 -2
- package/packages/cli/src/setup/runtime.ts +3 -2
- package/packages/cli/src/team-intake-mcp.ts +158 -0
- package/packages/cli/src/teams-mcp.ts +403 -0
- package/packages/cli/src/watchlist.ts +135 -0
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/profile.ts +163 -0
- package/packages/codex/src/sessions.ts +12 -0
- package/packages/codex/src/spoke.ts +19 -1
- package/packages/node/package.json +1 -1
- package/packages/node/src/config.ts +96 -2
- package/packages/node/src/opencode-warm.ts +325 -0
- package/packages/node/src/run.ts +27 -1
- package/packages/preset/package.json +1 -1
- package/packages/preset/src/profile.ts +34 -0
- package/packages/preset/src/spoke.ts +4 -0
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/artifacts.ts +142 -0
- package/packages/protocol/src/concierge.ts +366 -0
- package/packages/protocol/src/contracts.ts +104 -0
- package/packages/protocol/src/graph.ts +70 -0
- package/packages/protocol/src/hub-workspace.ts +201 -0
- package/packages/protocol/src/index.ts +169 -6
- package/packages/protocol/src/launch.ts +22 -0
- package/packages/protocol/src/profiles.ts +301 -0
- package/packages/protocol/src/teams.ts +204 -0
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/address.ts +50 -2
- package/packages/sdk/src/artifact-jobs.ts +194 -0
- package/packages/sdk/src/artifacts.ts +187 -0
- package/packages/sdk/src/client.ts +226 -5
- package/packages/sdk/src/concierge.ts +88 -0
- package/packages/sdk/src/errors.ts +3 -1
- package/packages/sdk/src/index.ts +5 -0
- package/packages/sdk/src/team-intake.ts +122 -0
- package/packages/sdk/src/teams.ts +346 -0
- package/packages/sdk/src/types.ts +548 -1
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/index.ts +1 -0
- package/packages/spoke-sdk/src/profile-apply.ts +245 -0
- package/src/central/actions.ts +7 -0
- package/src/central/agent/acting-routes.ts +2 -1
- package/src/central/agent/acting-switch.ts +2 -1
- package/src/central/agent/acting.ts +215 -10
- package/src/central/agent/app-tokens.ts +3 -2
- package/src/central/agent/audit.ts +17 -0
- package/src/central/agent/body.ts +163 -0
- package/src/central/agent/decisions.ts +69 -2
- package/src/central/agent/delivery.ts +95 -15
- package/src/central/agent/directory-routes.ts +37 -7
- package/src/central/agent/directory.ts +70 -14
- package/src/central/agent/errors.ts +5 -0
- package/src/central/agent/events-routes.ts +21 -4
- package/src/central/agent/events.ts +11 -3
- package/src/central/agent/form-check.ts +85 -0
- package/src/central/agent/grant-requests.ts +2 -1
- package/src/central/agent/grant-routes.ts +4 -3
- package/src/central/agent/grants.ts +29 -2
- package/src/central/agent/idempotency.ts +133 -0
- package/src/central/agent/intent-routes.ts +116 -0
- package/src/central/agent/intents.ts +505 -0
- package/src/central/agent/interaction-graph-routes.ts +212 -0
- package/src/central/agent/interaction-graph.ts +865 -0
- package/src/central/agent/launch-engine.ts +16 -2
- package/src/central/agent/launch-routes.ts +6 -1
- package/src/central/agent/launch-spec.ts +14 -1
- package/src/central/agent/message-routes.ts +2 -1
- package/src/central/agent/messages.ts +27 -2
- package/src/central/agent/names.ts +12 -2
- package/src/central/agent/read-routes.ts +86 -19
- package/src/central/agent/routes.ts +176 -5
- package/src/central/agent/webhook-routes.ts +2 -1
- package/src/central/agent-data.ts +4 -0
- package/src/central/artifacts/access.ts +44 -0
- package/src/central/artifacts/blob-store.ts +113 -0
- package/src/central/artifacts/blob-support.ts +98 -0
- package/src/central/artifacts/events.ts +27 -0
- package/src/central/artifacts/host-caps.ts +162 -0
- package/src/central/artifacts/host.ts +315 -0
- package/src/central/artifacts/idempotency.ts +109 -0
- package/src/central/artifacts/jobs-routes.ts +773 -0
- package/src/central/artifacts/jobs.ts +344 -0
- package/src/central/artifacts/maintenance.ts +103 -0
- package/src/central/artifacts/routes.ts +360 -0
- package/src/central/artifacts/store.ts +776 -0
- package/src/central/artifacts/workspaces.ts +31 -0
- package/src/central/backend/file-blobs.ts +377 -0
- package/src/central/backend/index.ts +12 -3
- package/src/central/backend/memory-blobs.ts +116 -0
- package/src/central/backend/postgres-blobs.ts +513 -0
- package/src/central/backend/postgres-migrate.ts +33 -0
- package/src/central/backend/postgres-state.ts +19 -0
- package/src/central/backend/types.ts +8 -0
- package/src/central/concierge/activity.ts +142 -0
- package/src/central/concierge/arm.ts +63 -0
- package/src/central/concierge/board-render.ts +190 -0
- package/src/central/concierge/board-results.ts +169 -0
- package/src/central/concierge/board-routes.ts +339 -0
- package/src/central/concierge/board.ts +249 -0
- package/src/central/concierge/context.ts +221 -0
- package/src/central/concierge/entitlements.ts +13 -0
- package/src/central/concierge/guard.ts +120 -0
- package/src/central/concierge/inbound.ts +89 -0
- package/src/central/concierge/index.ts +84 -0
- package/src/central/concierge/notes.ts +41 -0
- package/src/central/concierge/notifier.ts +986 -0
- package/src/central/concierge/prompt.ts +130 -0
- package/src/central/concierge/proposals.ts +262 -0
- package/src/central/concierge/push-remap.ts +49 -0
- package/src/central/concierge/routes.ts +62 -0
- package/src/central/concierge/schedule.ts +113 -0
- package/src/central/concierge/service.ts +243 -0
- package/src/central/concierge/session-routes.ts +69 -0
- package/src/central/concierge/settings.ts +217 -0
- package/src/central/concierge/supervisor.ts +740 -0
- package/src/central/concierge/tiers.ts +260 -0
- package/src/central/concierge/watchlist-routes.ts +277 -0
- package/src/central/concierge/watchlist.ts +273 -0
- package/src/central/config.ts +7 -1
- package/src/central/http.ts +633 -50
- package/src/central/mcp-http.ts +10 -21
- package/src/central/profiles/apply.ts +333 -0
- package/src/central/profiles/content.ts +174 -0
- package/src/central/profiles/events.ts +40 -0
- package/src/central/profiles/idempotency.ts +14 -0
- package/src/central/profiles/import.ts +139 -0
- package/src/central/profiles/routes.ts +356 -0
- package/src/central/profiles/store.ts +597 -0
- package/src/central/profiles/usage-ports.ts +120 -0
- package/src/central/profiles/usage.ts +364 -0
- package/src/central/push.ts +7 -0
- package/src/central/registry.ts +43 -6
- package/src/central/store.ts +4 -0
- package/src/central/teams/collections.ts +34 -0
- package/src/central/teams/discovery.ts +346 -0
- package/src/central/teams/draft-edits.ts +46 -0
- package/src/central/teams/events.ts +46 -0
- package/src/central/teams/intake-routes.ts +100 -0
- package/src/central/teams/intake.ts +772 -0
- package/src/central/teams/policy.ts +231 -0
- package/src/central/teams/routes.ts +516 -0
- package/src/central/teams/seats.ts +272 -0
- package/src/central/teams/staffing.ts +888 -0
- package/src/central/teams/store.ts +667 -0
- package/src/central/teams/validation.ts +256 -0
- package/src/central/workspaces/board.ts +408 -0
- package/src/central/workspaces/checkpoints.ts +316 -0
- package/src/central/workspaces/coordinator.ts +841 -0
- package/src/central/workspaces/events.ts +85 -0
- package/src/central/workspaces/lifecycle.ts +93 -0
- package/src/central/workspaces/master.ts +490 -0
- package/src/central/workspaces/members.ts +632 -0
- package/src/central/workspaces/routes.ts +354 -0
- package/src/central/workspaces/sidechats-routes.ts +410 -0
- package/src/central/workspaces/sidechats.ts +482 -0
- package/src/central/workspaces/store.ts +571 -0
- package/src/central/workspaces/supervision.ts +355 -0
- package/src/index.ts +97 -15
- package/src/spoke/concierge-agent.ts +33 -0
- package/src/spoke/deliver.ts +22 -4
- package/src/spoke/fork-revert.ts +142 -0
- package/src/spoke/hub-rpc.ts +41 -5
- package/src/spoke/hub-tool.ts +383 -18
- package/src/spoke/listing.ts +47 -4
- package/src/spoke/profile-apply.ts +298 -0
- package/src/spoke/session-context.ts +176 -0
- package/src/spoke/spoke.ts +319 -20
- package/src/spoke/timeline.ts +6 -1
- package/src/spoke/tracker.ts +23 -1
- package/src/spoke/who-teams.ts +81 -0
- package/src/tui-messaging.ts +32 -18
- package/web/dist/assets/ArtifactPage-BS0gVkfu.js +1 -0
- package/web/dist/assets/ArtifactPane-PLYHNs6s.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
- package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +6 -0
- package/web/dist/assets/Library-CtxRqTjo.js +2 -0
- package/web/dist/assets/Library-DYsFj_5c.css +1 -0
- package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +1 -0
- package/web/dist/assets/TeamsPage-BwjsAAUC.js +1 -0
- package/web/dist/assets/Watchtower-C4swrWVs.js +2 -0
- package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
- package/web/dist/assets/index-DaH5GUdV.js +31 -0
- package/web/dist/assets/index-WWuKfWxL.css +1 -0
- package/web/dist/assets/live-BUofsQpz.css +1 -0
- package/web/dist/assets/live-YyTjE47g.js +5 -0
- package/web/dist/assets/pane-layout-CKC4I00k.js +1 -0
- package/web/dist/assets/plural-Bspvoqfq.js +1 -0
- package/web/dist/assets/renderer2d-CX5j3NcD.js +1 -0
- package/web/dist/assets/renderer3d-BXIVOvfi.js +5393 -0
- package/web/dist/assets/safe-area-DfPsOdWR.js +1 -0
- package/web/dist/assets/source-CvUHTc3O.js +1 -0
- package/web/dist/assets/ui-C9Igxzjm.js +1 -0
- package/web/dist/index.html +5 -2
- package/web/dist/sw.js +22 -2
- package/web/dist/assets/index-Dm8OmkaO.css +0 -1
- package/web/dist/assets/index-FUJbj_Du.js +0 -20
|
@@ -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":
|
package/src/central/registry.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// another one and reaches it through SpokeHub.request (pod RPC). Events, alerts and speech stay with the pod that
|
|
12
12
|
// holds the connection; remote state is never re-exported.
|
|
13
13
|
import path from "node:path"
|
|
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"
|
|
14
|
+
import { HARNESS_OPENCODE, helloHarness, spokeFeatures, type EvidenceLevel, type ForwardedEvent, type Hello, type HerdrWorkspace, type ProcessKind, type SessionInfo, type SessionRevertInfo, type SpokeFeature, type SpokeMode, type TerminalInfo, type Terminals } from "../protocol"
|
|
15
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"
|
|
@@ -222,6 +222,11 @@ export interface SessionView extends BrowserFields {
|
|
|
222
222
|
/** Open in a terminal (any reporting process says so); where. */
|
|
223
223
|
readonly open?: boolean
|
|
224
224
|
readonly terminal?: TerminalInfo
|
|
225
|
+
/** A staged revert (feature "revert"), as the most recent report says. */
|
|
226
|
+
readonly revert?: SessionRevertInfo
|
|
227
|
+
/** (CON-04) A locked (Concierge) session: the agent and the context revision the most recent report says. */
|
|
228
|
+
readonly agent?: string
|
|
229
|
+
readonly contextRevision?: number
|
|
225
230
|
readonly busy: boolean
|
|
226
231
|
readonly conversation: boolean
|
|
227
232
|
/** Status tree of the root session that contains this session. */
|
|
@@ -405,6 +410,7 @@ export class Registry {
|
|
|
405
410
|
private readonly sessionsListeners = new Set<(connID: string) => void>()
|
|
406
411
|
/** Called when the user's own prompt reached a session's inbox (typed or spoken in the harness). */
|
|
407
412
|
private readonly humanTurnListeners = new Set<(sessionID: string, tenant?: string) => void>()
|
|
413
|
+
private readonly sessionDeletedListeners = new Set<(sessionID: string, tenant: string) => void>()
|
|
408
414
|
/** Pending form ID → its session and every connection that forwarded it, first first (like permissions). */
|
|
409
415
|
private readonly forms = new Map<string, { connIDs: string[]; sessionID: string }>()
|
|
410
416
|
/** Form IDs already alerted (forms may be forwarded by several processes). */
|
|
@@ -450,7 +456,7 @@ export class Registry {
|
|
|
450
456
|
private remoteBoards = new Map<string, SliceBoard>()
|
|
451
457
|
private remotePermissions = new Map<string, { sessionID: string; tenant: string; connIDs: string[]; request?: PermissionRequest; recommendation?: BlockerRecommendation }>()
|
|
452
458
|
private remoteForms = new Map<string, { sessionID: string; connIDs: string[] }>()
|
|
453
|
-
private remotePresence: Array<{ sessionID: string; at: number }> = []
|
|
459
|
+
private remotePresence: Array<{ deviceID?: string; sessionID: string; at: number }> = []
|
|
454
460
|
private remoteBusy = new Set<string>()
|
|
455
461
|
private remoteTerminals = new Map<string, MachineTerminals>()
|
|
456
462
|
|
|
@@ -573,6 +579,12 @@ export class Registry {
|
|
|
573
579
|
return () => this.humanTurnListeners.delete(listener)
|
|
574
580
|
}
|
|
575
581
|
|
|
582
|
+
/** Listens to sessions a spoke reports deleted (session.deleted, seen once by the pod holding the spoke). */
|
|
583
|
+
onSessionDeleted(listener: (sessionID: string, tenant: string) => void): () => void {
|
|
584
|
+
this.sessionDeletedListeners.add(listener)
|
|
585
|
+
return () => this.sessionDeletedListeners.delete(listener)
|
|
586
|
+
}
|
|
587
|
+
|
|
576
588
|
connect(connID: string, machine: MachineRef, hello: Hello, options: ConnectOptions = {}): void {
|
|
577
589
|
const t = this.now()
|
|
578
590
|
this.conns.set(connID, {
|
|
@@ -736,6 +748,7 @@ export class Registry {
|
|
|
736
748
|
for (const listener of [...this.humanTurnListeners]) listener(sessionID, tenant)
|
|
737
749
|
}
|
|
738
750
|
if (event.type === "session.deleted" && sessionID) this.setConversation(sessionID, false)
|
|
751
|
+
if (event.type === "session.deleted" && sessionID) for (const listener of [...this.sessionDeletedListeners]) listener(sessionID, tenant)
|
|
739
752
|
if (closedPermission) this.permissionClosed(...closedPermission)
|
|
740
753
|
}
|
|
741
754
|
|
|
@@ -1037,6 +1050,17 @@ export class Registry {
|
|
|
1037
1050
|
return false
|
|
1038
1051
|
}
|
|
1039
1052
|
|
|
1053
|
+
/**
|
|
1054
|
+
* (CON-06) True while this device reported this exact session visible within PRESENCE_WINDOW_MS (on any pod):
|
|
1055
|
+
* the voice-confirm check (a paired device with the Concierge open).
|
|
1056
|
+
*/
|
|
1057
|
+
present(deviceID: string, sessionID: string): boolean {
|
|
1058
|
+
const t = this.now()
|
|
1059
|
+
const local = this.presence.get(`${deviceID} ${sessionID}`)
|
|
1060
|
+
if (local && t - local.at <= PRESENCE_WINDOW_MS) return true
|
|
1061
|
+
return this.remotePresence.some((entry) => entry.deviceID === deviceID && entry.sessionID === sessionID && t - entry.at <= PRESENCE_WINDOW_MS)
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1040
1064
|
/** Whether any process exposes the session to voice; undefined when no process reports it. */
|
|
1041
1065
|
exposed(sessionID: string): boolean | undefined {
|
|
1042
1066
|
let reported = false
|
|
@@ -1248,6 +1272,9 @@ export class Registry {
|
|
|
1248
1272
|
...(info.projectName ? { projectName: info.projectName } : {}),
|
|
1249
1273
|
updated: info.updated,
|
|
1250
1274
|
...(terminal ? { open: true, terminal } : {}),
|
|
1275
|
+
...(info.revert ? { revert: info.revert } : {}),
|
|
1276
|
+
...(info.agent !== undefined ? { agent: info.agent } : {}),
|
|
1277
|
+
...(info.contextRevision !== undefined ? { contextRevision: info.contextRevision } : {}),
|
|
1251
1278
|
...fields,
|
|
1252
1279
|
busy: this.busy(sessionID),
|
|
1253
1280
|
conversation: this.conversations.has(sessionID),
|
|
@@ -1568,7 +1595,8 @@ export class Registry {
|
|
|
1568
1595
|
* Connection that should run a turn for the session:
|
|
1569
1596
|
* 1. a process reporting it as owned, most recent activity first;
|
|
1570
1597
|
* 2. else a service process that reports it;
|
|
1571
|
-
* 3. else the service process of a machine that reports it
|
|
1598
|
+
* 3. else the (never launch-bound) service process of a machine that reports it, of the same harness as its
|
|
1599
|
+
* reporters (never a service of another harness: a Claude Code service cannot serve an OpenCode session);
|
|
1572
1600
|
* 4. else any process that reports it, most recent activity first;
|
|
1573
1601
|
* 5. else none.
|
|
1574
1602
|
*/
|
|
@@ -1587,8 +1615,17 @@ export class Registry {
|
|
|
1587
1615
|
if (owned.length) return recent(owned)
|
|
1588
1616
|
const services = reporters.filter((c) => c.hello.kind === "service")
|
|
1589
1617
|
if (services.length) return recent(services)
|
|
1618
|
+
// A machine's service runs one harness: only a service of the session's harness (its reporters') may run it.
|
|
1590
1619
|
const machines = new Set(reporters.map((c) => c.machine.id))
|
|
1591
|
-
|
|
1620
|
+
const harnesses = new Set(reporters.map((c) => helloHarness(c.hello).harness))
|
|
1621
|
+
const machineServices = all.filter(
|
|
1622
|
+
(c) =>
|
|
1623
|
+
c.hello.kind === "service" &&
|
|
1624
|
+
c.launchID === undefined &&
|
|
1625
|
+
machines.has(c.machine.id) &&
|
|
1626
|
+
harnesses.has(helloHarness(c.hello).harness),
|
|
1627
|
+
)
|
|
1628
|
+
return recent(machineServices) ?? recent(reporters)
|
|
1592
1629
|
}
|
|
1593
1630
|
|
|
1594
1631
|
/**
|
|
@@ -1910,7 +1947,7 @@ export class Registry {
|
|
|
1910
1947
|
const boards = new Map<string, SliceBoard>()
|
|
1911
1948
|
const permissions = new Map<string, { sessionID: string; tenant: string; connIDs: string[]; request?: PermissionRequest; recommendation?: BlockerRecommendation }>()
|
|
1912
1949
|
const forms = new Map<string, { sessionID: string; connIDs: string[] }>()
|
|
1913
|
-
const presence: Array<{ sessionID: string; at: number }> = []
|
|
1950
|
+
const presence: Array<{ deviceID?: string; sessionID: string; at: number }> = []
|
|
1914
1951
|
const busy = new Set<string>()
|
|
1915
1952
|
const terminals = new Map<string, MachineTerminals>()
|
|
1916
1953
|
for (const [pod, slices] of this.remoteSlices) {
|
|
@@ -1944,7 +1981,7 @@ export class Registry {
|
|
|
1944
1981
|
if (known) known.connIDs.push(...f.connIDs.filter((id) => !known.connIDs.includes(id)))
|
|
1945
1982
|
else forms.set(f.formID, { sessionID: f.sessionID, connIDs: [...f.connIDs] })
|
|
1946
1983
|
}
|
|
1947
|
-
for (const entry of slice.presence ?? []) presence.push({ sessionID: entry.sessionID, at: entry.at })
|
|
1984
|
+
for (const entry of slice.presence ?? []) presence.push({ ...(typeof entry.deviceID === "string" ? { deviceID: entry.deviceID } : {}), sessionID: entry.sessionID, at: entry.at })
|
|
1948
1985
|
for (const sessionID of slice.busy ?? []) busy.add(sessionID)
|
|
1949
1986
|
}
|
|
1950
1987
|
}
|