@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,113 @@
|
|
|
1
|
+
// CON-05 schedule (contract: "Event-driven turns, digests, quiet hours (CON-05)"): wall-clock time in IANA zones for
|
|
2
|
+
// the Concierge's quiet hours (their own `timeZone`) and digests (`settings.timeZone`). Pure; Intl only.
|
|
3
|
+
//
|
|
4
|
+
// - An invalid or unknown zone (settings validate zones, but the runtime's zone data can change) is UTC.
|
|
5
|
+
// - A wall time in a spring-forward gap (02:30 on the night clocks jump 02:00 → 03:00) moves forward by the gap
|
|
6
|
+
// (03:30), as cron and Temporal's "compatible" do; one in a fall-back overlap is its first occurrence.
|
|
7
|
+
// - A digest is due from its minute for DIGEST_GRACE_MS (the leader ticks every 30 s; a failover or an enable after
|
|
8
|
+
// the minute does not send it late), keyed `digest:<local date>:<entry index>`: the notifier's ledger sends each
|
|
9
|
+
// key once, so a fall-back day that repeats the minute still sends it once.
|
|
10
|
+
import type { ConciergeDigest, ConciergeQuietHours } from "../../../packages/protocol/src/concierge"
|
|
11
|
+
|
|
12
|
+
const MIN = 60_000
|
|
13
|
+
const DAY = 86_400_000
|
|
14
|
+
/** How long after its minute a digest may still go out. */
|
|
15
|
+
export const DIGEST_GRACE_MS = 60 * MIN
|
|
16
|
+
|
|
17
|
+
export interface LocalTime {
|
|
18
|
+
/** YYYY-MM-DD in the zone. */
|
|
19
|
+
readonly date: string
|
|
20
|
+
/** Minutes since local midnight (0–1439). */
|
|
21
|
+
readonly minutes: number
|
|
22
|
+
/** 0 = Sunday … 6 = Saturday. */
|
|
23
|
+
readonly weekday: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface DueDigest {
|
|
27
|
+
readonly key: string
|
|
28
|
+
readonly index: number
|
|
29
|
+
/** The entry's HH:MM. */
|
|
30
|
+
readonly label: string
|
|
31
|
+
readonly occurredAt: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const formats = new Map<string, Intl.DateTimeFormat>()
|
|
35
|
+
const FORMATS_MAX = 200
|
|
36
|
+
|
|
37
|
+
function format(zone: string): Intl.DateTimeFormat | undefined {
|
|
38
|
+
let f = formats.get(zone)
|
|
39
|
+
if (f) return f
|
|
40
|
+
try {
|
|
41
|
+
f = new Intl.DateTimeFormat("en-US", { timeZone: zone, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hourCycle: "h23" })
|
|
42
|
+
} catch {
|
|
43
|
+
return undefined
|
|
44
|
+
}
|
|
45
|
+
if (formats.size >= FORMATS_MAX) formats.delete(formats.keys().next().value as string)
|
|
46
|
+
formats.set(zone, f)
|
|
47
|
+
return f
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The zone, or UTC when it is not a zone this runtime knows. */
|
|
51
|
+
export function zoneOrUTC(zone: string): string {
|
|
52
|
+
return typeof zone === "string" && zone.length > 0 && zone.length <= 64 && format(zone) ? zone : "UTC"
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The wall-clock fields at `at` in `zone`, as a UTC timestamp of the same fields (minute resolution). */
|
|
56
|
+
function wall(at: number, zone: string): number {
|
|
57
|
+
const parts: Record<string, number> = {}
|
|
58
|
+
for (const p of format(zone)!.formatToParts(at)) if (p.type !== "literal") parts[p.type] = Number(p.value)
|
|
59
|
+
return Date.UTC(parts.year!, parts.month! - 1, parts.day!, parts.hour! % 24, parts.minute!)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const pad = (n: number) => String(n).padStart(2, "0")
|
|
63
|
+
const dateOf = (utcFields: number) => {
|
|
64
|
+
const d = new Date(utcFields)
|
|
65
|
+
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function localTime(at: number, zone: string): LocalTime {
|
|
69
|
+
const fields = wall(at, zoneOrUTC(zone))
|
|
70
|
+
const d = new Date(fields)
|
|
71
|
+
return { date: dateOf(fields), minutes: d.getUTCHours() * 60 + d.getUTCMinutes(), weekday: d.getUTCDay() }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const clockMinutes = (hhmm: string) => Number(hhmm.slice(0, 2)) * 60 + Number(hhmm.slice(3, 5))
|
|
75
|
+
|
|
76
|
+
/** The instant of wall time `minutes` on local `date` (YYYY-MM-DD) in `zone` (gaps forward, overlaps first). */
|
|
77
|
+
export function zonedInstant(date: string, minutes: number, zone: string): number {
|
|
78
|
+
const z = zoneOrUTC(zone)
|
|
79
|
+
const [y, m, d] = date.split("-").map(Number) as [number, number, number]
|
|
80
|
+
const target = Date.UTC(y, m - 1, d, 0, minutes)
|
|
81
|
+
const offset = (t: number) => wall(t, z) - Math.floor(t / MIN) * MIN
|
|
82
|
+
// The offsets a day either side: at most one transition lies between them.
|
|
83
|
+
const candidates = [...new Set([target - offset(target - DAY), target - offset(target + DAY)])]
|
|
84
|
+
const valid = candidates.filter((t) => wall(t, z) === target)
|
|
85
|
+
return valid.length ? Math.min(...valid) : Math.max(...candidates)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The digests due at `at` (from their minute, for `graceMs`), oldest local date first per entry. */
|
|
89
|
+
export function dueDigests(digests: readonly ConciergeDigest[], zone: string, at: number, graceMs: number = DIGEST_GRACE_MS): DueDigest[] {
|
|
90
|
+
const z = zoneOrUTC(zone)
|
|
91
|
+
const dates = [...new Set([localTime(at - graceMs, z).date, localTime(at, z).date])]
|
|
92
|
+
const out: DueDigest[] = []
|
|
93
|
+
digests.forEach((digest, index) => {
|
|
94
|
+
for (const date of dates) {
|
|
95
|
+
const [y, m, d] = date.split("-").map(Number) as [number, number, number]
|
|
96
|
+
const weekday = new Date(Date.UTC(y, m - 1, d)).getUTCDay() as 0 | 1 | 2 | 3 | 4 | 5 | 6
|
|
97
|
+
if (digest.days && !digest.days.includes(weekday)) continue
|
|
98
|
+
const occurredAt = zonedInstant(date, clockMinutes(digest.at), z)
|
|
99
|
+
if (occurredAt <= at && at < occurredAt + graceMs) out.push({ key: `digest:${date}:${index}`, index, label: digest.at, occurredAt })
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
return out
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Whether `at` falls in the quiet hours (start inclusive, end exclusive; across midnight when start > end). */
|
|
106
|
+
export function inQuietHours(quiet: ConciergeQuietHours | undefined, at: number): boolean {
|
|
107
|
+
if (!quiet) return false
|
|
108
|
+
const start = clockMinutes(quiet.start)
|
|
109
|
+
const end = clockMinutes(quiet.end)
|
|
110
|
+
if (start === end) return false
|
|
111
|
+
const { minutes } = localTime(at, quiet.timeZone)
|
|
112
|
+
return start < end ? minutes >= start && minutes < end : minutes >= start || minutes < end
|
|
113
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// CON-04 the Concierge service (contract "Concierge service: settings, launch, supervise, hide, context"): what the
|
|
2
|
+
// Settings routes do, on top of the settings and notes records, the supervisor (state record, launch, relaunch,
|
|
3
|
+
// disable) and the grant. Users only (routes.ts); every operation is keyed by the user (the tenant).
|
|
4
|
+
//
|
|
5
|
+
// - Machine eligibility (eligibilityOf, pure): one of the user's machines with a hub node (host runtime with OpenCode,
|
|
6
|
+
// workspace roots, not draining) and a connected OpenCode service spoke announcing `launch` and `sessionContext`.
|
|
7
|
+
// Each "no" has a reason Settings shows. An offline machine is not eligible now but may be enabled: the state is
|
|
8
|
+
// waiting_machine until it is back.
|
|
9
|
+
// - Enable: entitlements.check (403 with its reason), machineID and directory required (400), the machine must be
|
|
10
|
+
// the user's (404 otherwise, like anything of another user), an online machine must qualify and the directory be
|
|
11
|
+
// under its node's workspace roots (422 unsupported {feature: "conciergeMachine", reason}). Then the grant (when
|
|
12
|
+
// settings.grant: Grants.create/update, holder @@concierge), the settings (enabled, version-checked), and the
|
|
13
|
+
// supervisor starts it. A grant created for a refused enable is revoked again.
|
|
14
|
+
// - A settings change that picks a machine that is not the user's (unknown or another user's, alike): 404 (CON-09b).
|
|
15
|
+
// - A settings change while enabled: a new or removed grant applies at once (update restarts its expiry: Renew);
|
|
16
|
+
// another machine, directory or model restarts the Concierge.
|
|
17
|
+
// - Disable: settings first (enabled false, version-checked), then the supervisor's idempotent steps.
|
|
18
|
+
import type { ConciergeSettings, ConciergeStatus } from "../../../packages/protocol/src/concierge"
|
|
19
|
+
import type { NodeRuntimeInfo } from "../../protocol"
|
|
20
|
+
import { AgentError } from "../agent/errors"
|
|
21
|
+
import type { Grants } from "../agent/grants"
|
|
22
|
+
import type { ConciergeEntitlements } from "./entitlements"
|
|
23
|
+
import type { ConciergeNotes, NotesStore } from "./notes"
|
|
24
|
+
import { grantInput, parseSettingsPatch, type SettingsStore, validTimeZone } from "./settings"
|
|
25
|
+
import type { Eligibility, Supervisor } from "./supervisor"
|
|
26
|
+
|
|
27
|
+
/** What the hub knows about one of the user's machines (http.ts gathers it from the store, nodes and registry). */
|
|
28
|
+
export interface MachineFacts {
|
|
29
|
+
readonly id: string
|
|
30
|
+
readonly name: string
|
|
31
|
+
/** A process of the machine is connected (spoke or node). */
|
|
32
|
+
readonly online: boolean
|
|
33
|
+
readonly node?: { readonly runtimes: readonly NodeRuntimeInfo[]; readonly workspaceRoots: readonly string[]; readonly draining: boolean }
|
|
34
|
+
/** The machine's OpenCode service spoke and what it announces. */
|
|
35
|
+
readonly service?: { readonly launch: boolean; readonly sessionContext: boolean }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface MachineView extends Eligibility {
|
|
39
|
+
readonly id: string
|
|
40
|
+
readonly name: string
|
|
41
|
+
/** The node's workspace roots (the directory picker's start points). */
|
|
42
|
+
readonly roots: readonly string[]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Whether a machine can host the Concierge now, and why not. */
|
|
46
|
+
export function eligibilityOf(facts: Omit<MachineFacts, "id" | "name">): Eligibility {
|
|
47
|
+
const no = (reason: string): Eligibility => ({ eligible: false, online: facts.online, reason })
|
|
48
|
+
if (!facts.node && !facts.online) return no("Offline")
|
|
49
|
+
if (!facts.node) return no("No hub node runs on it: start `hub node` there with a workspace root")
|
|
50
|
+
if (facts.node.draining) return no("Its hub node is draining")
|
|
51
|
+
if (!facts.node.runtimes.some((r) => r.kind === "host" && r.harnesses?.includes("opencode"))) return no("Its hub node has no host runtime with OpenCode")
|
|
52
|
+
if (!facts.node.workspaceRoots.length) return no("Its hub node has no workspace roots")
|
|
53
|
+
if (!facts.service) return no("No OpenCode service runs on it (the hub plugin's background service)")
|
|
54
|
+
if (!facts.service.launch) return no("Its OpenCode plugin cannot launch sessions: update the hub plugin")
|
|
55
|
+
if (!facts.service.sessionContext) return no("Its OpenCode plugin is too old for the Concierge: update the hub plugin")
|
|
56
|
+
return { eligible: true, online: true }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const underRoot = (dir: string, root: string) => {
|
|
60
|
+
const base = root.replace(/\/+$/, "")
|
|
61
|
+
return dir === base || dir.startsWith(`${base}/`)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface ConciergeView {
|
|
65
|
+
readonly settings: ConciergeSettings
|
|
66
|
+
readonly status: ConciergeStatus
|
|
67
|
+
readonly machines: readonly MachineView[]
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface ServiceDeps {
|
|
71
|
+
readonly settings: SettingsStore
|
|
72
|
+
readonly notes: NotesStore
|
|
73
|
+
readonly supervisor: Supervisor
|
|
74
|
+
readonly grants: Pick<Grants, "create" | "update" | "revoke">
|
|
75
|
+
/** The user's machines (not revoked) with their facts. */
|
|
76
|
+
readonly machines: (userID: string) => readonly MachineFacts[]
|
|
77
|
+
readonly entitlements: ConciergeEntitlements
|
|
78
|
+
/** The admin audit: {actor: owner, action: concierge.*} only. */
|
|
79
|
+
readonly audit: (userID: string, action: string) => Promise<void>
|
|
80
|
+
/**
|
|
81
|
+
* Makes buffered state writes durable (StateStore.flush; a write-through store has none). Awaited before every
|
|
82
|
+
* security-relevant answer (settings, enable, disable, restart): a 200 is never lost to a crash.
|
|
83
|
+
*/
|
|
84
|
+
readonly flush?: () => Promise<void>
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const ignorable = (cause: unknown) => cause instanceof AgentError && (cause.code === "gone" || cause.code === "not_found")
|
|
88
|
+
const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
|
|
89
|
+
|
|
90
|
+
function revisionOf(body: unknown, allowed: readonly string[] = []): number {
|
|
91
|
+
if (!isObject(body)) throw new AgentError("invalid", "The body is {expectedRevision}")
|
|
92
|
+
for (const key of Object.keys(body)) if (key !== "expectedRevision" && !allowed.includes(key)) throw new AgentError("invalid", `Unknown field ${key.slice(0, 40)}`)
|
|
93
|
+
if (!Number.isSafeInteger(body.expectedRevision)) throw new AgentError("invalid", "expectedRevision is required")
|
|
94
|
+
return body.expectedRevision as number
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const checkRevision = (settings: ConciergeSettings, expected: number) => {
|
|
98
|
+
if (settings.revision !== expected) throw new AgentError("conflict", "The Concierge settings changed meanwhile", { extra: { currentRevision: settings.revision } })
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export class ConciergeService {
|
|
102
|
+
constructor(private readonly deps: ServiceDeps) {}
|
|
103
|
+
|
|
104
|
+
get supervisor(): Supervisor {
|
|
105
|
+
return this.deps.supervisor
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The user's machines with their eligibility. */
|
|
109
|
+
machineViews(userID: string): MachineView[] {
|
|
110
|
+
return this.deps.machines(userID).map((m) => ({ id: m.id, name: m.name, ...eligibilityOf(m), roots: m.node ? [...m.node.workspaceRoots] : [] }))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The supervisor's eligibility port. */
|
|
114
|
+
eligibility(userID: string, machineID: string): Eligibility {
|
|
115
|
+
const facts = this.deps.machines(userID).find((m) => m.id === machineID)
|
|
116
|
+
return facts ? eligibilityOf(facts) : { eligible: false, online: false, reason: "Not one of your machines" }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async view(userID: string): Promise<ConciergeView> {
|
|
120
|
+
const [settings, status] = await Promise.all([this.deps.settings.get(userID), this.deps.supervisor.status(userID)])
|
|
121
|
+
return { settings, status, machines: this.machineViews(userID) }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Refuses a machine or directory that cannot host the Concierge (an offline machine is fine). */
|
|
125
|
+
private checkPlacement(userID: string, settings: ConciergeSettings): void {
|
|
126
|
+
if (settings.machineID === undefined || settings.directory === undefined) throw new AgentError("invalid", "Pick a machine and a directory (a git repo on it) first")
|
|
127
|
+
const facts = this.deps.machines(userID).find((m) => m.id === settings.machineID)
|
|
128
|
+
if (!facts) throw new AgentError("not_found", "No such machine")
|
|
129
|
+
const unsupported = (reason: string) => new AgentError("unsupported", `This machine cannot host the Concierge: ${reason}`, { extra: { feature: "conciergeMachine", reason } })
|
|
130
|
+
const verdict = eligibilityOf(facts)
|
|
131
|
+
if (!verdict.eligible && verdict.online) throw unsupported(verdict.reason ?? "not eligible")
|
|
132
|
+
if (facts.node && !facts.node.workspaceRoots.some((root) => underRoot(settings.directory!, root))) throw unsupported("The directory is not under the machine's workspace roots")
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Creates, renews or revokes the @@concierge grant for the settings; the grant ID (undefined: none). */
|
|
136
|
+
private async applyGrant(userID: string, settings: ConciergeSettings): Promise<string | undefined> {
|
|
137
|
+
if (!settings.grant) {
|
|
138
|
+
if (settings.grantID !== undefined) await this.deps.grants.revoke(userID, settings.grantID).catch((cause) => (ignorable(cause) ? undefined : Promise.reject(cause)))
|
|
139
|
+
return undefined
|
|
140
|
+
}
|
|
141
|
+
if (settings.grantID !== undefined) {
|
|
142
|
+
try {
|
|
143
|
+
await this.deps.grants.update(userID, settings.grantID, grantInput(settings.grant), { managed: true })
|
|
144
|
+
return settings.grantID
|
|
145
|
+
} catch (cause) {
|
|
146
|
+
if (!ignorable(cause)) throw cause
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return (await this.deps.grants.create(userID, grantInput(settings.grant), { managed: true })).id
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private async withGrantID(userID: string, expected: number, grantID: string | undefined, change: (s: ConciergeSettings) => ConciergeSettings): Promise<ConciergeSettings> {
|
|
153
|
+
return this.deps.settings.update(userID, (current) => {
|
|
154
|
+
checkRevision(current, expected)
|
|
155
|
+
const next = change(current)
|
|
156
|
+
const { grantID: _old, ...rest } = next
|
|
157
|
+
return grantID !== undefined ? { ...rest, grantID } : rest
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async updateSettings(userID: string, body: unknown): Promise<{ settings: ConciergeSettings; status: ConciergeStatus }> {
|
|
162
|
+
const current = await this.deps.settings.get(userID)
|
|
163
|
+
const next = parseSettingsPatch(body, current)
|
|
164
|
+
// CON-09b: a machine that is not the user's is refused when it is saved, not only at enable. An unknown ID and
|
|
165
|
+
// another user's answer alike (no oracle). A machine saved earlier and removed since is only checked at enable.
|
|
166
|
+
if (next.machineID !== undefined && next.machineID !== current.machineID && !this.deps.machines(userID).some((m) => m.id === next.machineID)) {
|
|
167
|
+
throw new AgentError("not_found", "That machine is not one of yours: pick one of your machines")
|
|
168
|
+
}
|
|
169
|
+
const placementChanged = next.machineID !== current.machineID || next.directory !== current.directory || next.model !== current.model
|
|
170
|
+
if (current.enabled && placementChanged) this.checkPlacement(userID, next)
|
|
171
|
+
const grantTouched = isObject(body) && "grant" in body
|
|
172
|
+
const grantID = current.enabled && grantTouched ? await this.applyGrant(userID, next) : current.grantID
|
|
173
|
+
const settings = await this.withGrantID(userID, current.revision, grantID, () => next)
|
|
174
|
+
await this.deps.audit(userID, "concierge.settings")
|
|
175
|
+
const status = current.enabled && placementChanged ? await this.deps.supervisor.restart(userID) : await this.deps.supervisor.status(userID)
|
|
176
|
+
await this.durable()
|
|
177
|
+
return { settings, status }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async enable(userID: string, body: unknown): Promise<{ settings: ConciergeSettings; status: ConciergeStatus }> {
|
|
181
|
+
const expected = revisionOf(body, ["timeZone"])
|
|
182
|
+
const timeZone = (body as { timeZone?: unknown }).timeZone
|
|
183
|
+
if (timeZone !== undefined && !validTimeZone(timeZone)) throw new AgentError("invalid", "timeZone is an IANA time zone")
|
|
184
|
+
const entitled = await this.deps.entitlements.check(userID)
|
|
185
|
+
if (!entitled.ok) throw new AgentError("forbidden", entitled.reason)
|
|
186
|
+
const current = await this.deps.settings.get(userID)
|
|
187
|
+
checkRevision(current, expected)
|
|
188
|
+
this.checkPlacement(userID, current)
|
|
189
|
+
const grantID = await this.applyGrant(userID, current)
|
|
190
|
+
let settings: ConciergeSettings
|
|
191
|
+
try {
|
|
192
|
+
settings = await this.withGrantID(userID, expected, grantID, (s) => ({ ...s, enabled: true, ...(typeof timeZone === "string" && s.updatedAt === undefined ? { timeZone } : {}) }))
|
|
193
|
+
} catch (cause) {
|
|
194
|
+
if (grantID !== undefined && grantID !== current.grantID) await this.deps.grants.revoke(userID, grantID).catch(() => undefined)
|
|
195
|
+
throw cause
|
|
196
|
+
}
|
|
197
|
+
await this.deps.audit(userID, "concierge.enable")
|
|
198
|
+
const status = await this.deps.supervisor.enable(userID)
|
|
199
|
+
await this.durable()
|
|
200
|
+
return { settings, status }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async disable(userID: string, body: unknown): Promise<{ settings: ConciergeSettings; status: ConciergeStatus }> {
|
|
204
|
+
const expected = revisionOf(body)
|
|
205
|
+
const current = await this.deps.settings.get(userID)
|
|
206
|
+
checkRevision(current, expected)
|
|
207
|
+
if (current.enabled) await this.withGrantID(userID, expected, current.grantID, (s) => ({ ...s, enabled: false }))
|
|
208
|
+
const status = await this.deps.supervisor.disable(userID)
|
|
209
|
+
await this.durable()
|
|
210
|
+
return { settings: await this.deps.settings.get(userID), status }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async restart(userID: string): Promise<{ status: ConciergeStatus }> {
|
|
214
|
+
const settings = await this.deps.settings.get(userID)
|
|
215
|
+
if (!settings.enabled) throw new AgentError("conflict", "The Concierge is disabled: enable it first")
|
|
216
|
+
const entitled = await this.deps.entitlements.check(userID)
|
|
217
|
+
if (!entitled.ok) throw new AgentError("forbidden", entitled.reason)
|
|
218
|
+
// A suspended Concierge lost its grant: the user's Restart grants it again (the standing setting).
|
|
219
|
+
if (settings.grant && settings.grantID === undefined) {
|
|
220
|
+
const grantID = await this.applyGrant(userID, settings)
|
|
221
|
+
if (grantID !== undefined) await this.deps.settings.update(userID, (s) => ({ ...s, grantID }))
|
|
222
|
+
}
|
|
223
|
+
const status = await this.deps.supervisor.restart(userID)
|
|
224
|
+
await this.durable()
|
|
225
|
+
return { status }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Flushes the state store (see ServiceDeps.flush). */
|
|
229
|
+
private async durable(): Promise<void> {
|
|
230
|
+
await this.deps.flush?.()
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
notes(userID: string): Promise<ConciergeNotes> {
|
|
234
|
+
return this.deps.notes.get(userID)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async setNotes(userID: string, body: unknown): Promise<ConciergeNotes> {
|
|
238
|
+
const expected = revisionOf(body, ["text"])
|
|
239
|
+
const text = (body as { text?: unknown }).text
|
|
240
|
+
if (typeof text !== "string") throw new AgentError("invalid", "text is required (a string, at most 32 KiB)")
|
|
241
|
+
return this.deps.notes.put(userID, text, expected)
|
|
242
|
+
}
|
|
243
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// CON-04 the routes a Concierge session may call (round-3 verify): its session tokens (act AND shell, the current
|
|
2
|
+
// session and every previous one) reach ONLY the routes listed here; every other /api/agent route answers 403
|
|
3
|
+
// forbidden {policy: "concierge"} before its handler runs. The check is central (agentApi's gate), so a route added
|
|
4
|
+
// later is refused until it is listed here (fail closed).
|
|
5
|
+
//
|
|
6
|
+
// What the list keeps: reads (who, status, sessions, its inbox and messages), messages to @me and replies/declines
|
|
7
|
+
// to asks addressed to it (the outbound rule narrows msg/ask/reply/decline further, inbound.ts), the grant-gated act
|
|
8
|
+
// verbs, its own notes, its board and Decision reads, and the watchlist verbs. Nothing that puts its text in front
|
|
9
|
+
// of another session (intake, artifacts, workspaces, side chats, profiles, names, intents, webhooks, teams…).
|
|
10
|
+
// CON-06's own routes are added through the tiers slot (ConciergeTiers.routes), which are Concierge routes by design.
|
|
11
|
+
import type { AgentActor } from "../agent/actor"
|
|
12
|
+
import { AgentError } from "../agent/errors"
|
|
13
|
+
import type { AgentRoute } from "../agent/routes"
|
|
14
|
+
import type { ConciergeIdentity } from "./watchlist-routes"
|
|
15
|
+
|
|
16
|
+
const A = "/api/agent"
|
|
17
|
+
const C = `${A}/concierge`
|
|
18
|
+
|
|
19
|
+
export const CONCIERGE_SESSION_ROUTES: readonly { readonly method: string; readonly path: string }[] = [
|
|
20
|
+
{ method: "GET", path: `${A}/whoami` },
|
|
21
|
+
{ method: "GET", path: `${A}/who` },
|
|
22
|
+
{ method: "GET", path: `${A}/status` },
|
|
23
|
+
{ method: "GET", path: `${A}/sessions` },
|
|
24
|
+
{ method: "GET", path: `${A}/sessions/:addr` },
|
|
25
|
+
{ method: "POST", path: `${A}/msg` },
|
|
26
|
+
{ method: "POST", path: `${A}/ask` },
|
|
27
|
+
{ method: "POST", path: `${A}/reply` },
|
|
28
|
+
{ method: "POST", path: `${A}/decline` },
|
|
29
|
+
{ method: "GET", path: `${A}/inbox` },
|
|
30
|
+
{ method: "GET", path: `${A}/messages/:id` },
|
|
31
|
+
...["approve", "deny", "answer", "prompt", "stop"].map((verb) => ({ method: "POST", path: `${A}/sessions/:addr/${verb}` })),
|
|
32
|
+
{ method: "GET", path: `${C}/notes` },
|
|
33
|
+
{ method: "PUT", path: `${C}/notes` },
|
|
34
|
+
{ method: "GET", path: `${C}/board` },
|
|
35
|
+
{ method: "GET", path: `${C}/decisions` },
|
|
36
|
+
{ method: "GET", path: `${C}/decisions/:id` },
|
|
37
|
+
{ method: "GET", path: `${C}/watchlist` },
|
|
38
|
+
...["watch", "unwatch", "mute", "unmute"].map((verb) => ({ method: "POST", path: `${C}/watchlist/${verb}` })),
|
|
39
|
+
// CON-06 (tiers.ts): proposals and arming. Proposals put no Concierge text in front of another session: free text
|
|
40
|
+
// (prompt, form answers) runs only after the user's tap.
|
|
41
|
+
{ method: "POST", path: `${C}/proposals` },
|
|
42
|
+
{ method: "GET", path: `${C}/arm` },
|
|
43
|
+
{ method: "POST", path: `${C}/arm` },
|
|
44
|
+
// CON-13 (tiers.ts): a pending form's schema (fields, options), so its answers fit the form.
|
|
45
|
+
{ method: "GET", path: `${C}/sessions/:addr/forms/:form` },
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
const key = (method: string, path: string) => `${method} ${path}`
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* (FX-07) How long a Concierge identity check waits for the state store (the route gate, the acting guard, the inbound
|
|
52
|
+
* and outbound rules, team staffing): past it the check fails closed with `busy` (503).
|
|
53
|
+
*/
|
|
54
|
+
export const IDENTITY_TIMEOUT_MS = 5_000
|
|
55
|
+
|
|
56
|
+
/** (FX-07) The refusal while the hub's state does not answer: neutral (no Concierge wording), retried by clients. */
|
|
57
|
+
export const stateBusy = () => new AgentError("busy", "Hub state is busy, try again", { extra: { retryAfter: Math.ceil(IDENTITY_TIMEOUT_MS / 1000) } })
|
|
58
|
+
|
|
59
|
+
/** `read`, or a `busy` rejection once `ms` passed (the timer never outlives the answer; a late answer is ignored). */
|
|
60
|
+
function within<T>(read: Promise<T>, ms: number): Promise<T> {
|
|
61
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
62
|
+
const late = new Promise<never>((_, reject) => {
|
|
63
|
+
timer = setTimeout(() => reject(stateBusy()), ms)
|
|
64
|
+
})
|
|
65
|
+
read.catch(() => {}) // a late rejection is not unhandled
|
|
66
|
+
return Promise.race([read, late]).finally(() => clearTimeout(timer))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const TIMED = Symbol("concierge.timedIdentity")
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* (FX-07) The identity with a time limit on each read: past it, the read rejects `busy`. A user whose read timed out
|
|
73
|
+
* is answered `busy` at once for the next `ms` (no new store read: a stuck store is not asked again per check, so
|
|
74
|
+
* the checks of one request share one wait and reads do not pile up). Wrapping a timed identity returns it as is.
|
|
75
|
+
*/
|
|
76
|
+
export function timedIdentity(identity: ConciergeIdentity, ms: number = IDENTITY_TIMEOUT_MS, now: () => number = Date.now): ConciergeIdentity {
|
|
77
|
+
if ((identity as { [TIMED]?: true })[TIMED]) return identity
|
|
78
|
+
const stuckUntil = new Map<string, number>()
|
|
79
|
+
const timed = {
|
|
80
|
+
[TIMED]: true as const,
|
|
81
|
+
async current(userID: string) {
|
|
82
|
+
const until = stuckUntil.get(userID)
|
|
83
|
+
if (until !== undefined) {
|
|
84
|
+
if (now() < until) throw stateBusy()
|
|
85
|
+
stuckUntil.delete(userID)
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
return await within(identity.current(userID), ms)
|
|
89
|
+
} catch (cause) {
|
|
90
|
+
if (cause instanceof AgentError && cause.code === "busy") stuckUntil.set(userID, now() + ms)
|
|
91
|
+
throw cause
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
return timed
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The gate agentApi runs for every session actor before the route's own checks: a Concierge session (current or
|
|
100
|
+
* previous) may call only the allow-list and `extra` (the tiers slot's routes). (FX-07) An identity read that fails or
|
|
101
|
+
* has not answered within `timeoutMs` refuses every other route with a neutral 503 `busy` (fail closed, without
|
|
102
|
+
* telling an ordinary session it is the Concierge); the allow-list still passes.
|
|
103
|
+
*/
|
|
104
|
+
export function conciergeSessionGate(
|
|
105
|
+
identity: ConciergeIdentity,
|
|
106
|
+
extra: () => readonly AgentRoute[] = () => [],
|
|
107
|
+
options: { readonly timeoutMs?: number } = {},
|
|
108
|
+
): (actor: AgentActor, route: Pick<AgentRoute, "method" | "path">) => Promise<void> {
|
|
109
|
+
const allowed = new Set(CONCIERGE_SESSION_ROUTES.map((r) => key(r.method, r.path)))
|
|
110
|
+
const timed = timedIdentity(identity, options.timeoutMs ?? IDENTITY_TIMEOUT_MS)
|
|
111
|
+
return async (actor, route) => {
|
|
112
|
+
if (actor.kind !== "session") return
|
|
113
|
+
const listed = () => allowed.has(key(route.method, route.path)) || extra().some((r) => r.method === route.method && r.path === route.path)
|
|
114
|
+
let concierge: boolean
|
|
115
|
+
try {
|
|
116
|
+
const current = await timed.current(actor.userID)
|
|
117
|
+
concierge = current.sessionID === actor.sessionID || current.hiddenSessionIDs.includes(actor.sessionID)
|
|
118
|
+
} catch {
|
|
119
|
+
if (listed()) return
|
|
120
|
+
throw stateBusy()
|
|
121
|
+
}
|
|
122
|
+
if (!concierge || listed()) return
|
|
123
|
+
throw new AgentError("forbidden", "The Concierge cannot use this route", { extra: { policy: "concierge" } })
|
|
124
|
+
}
|
|
125
|
+
}
|