@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
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. */
|
|
@@ -248,6 +253,8 @@ export interface CatalogProcess {
|
|
|
248
253
|
readonly connected: number
|
|
249
254
|
readonly lastSeen: number
|
|
250
255
|
readonly sessions: CatalogSession[]
|
|
256
|
+
/** (FX-07) Connected, but its first sessions frame has not come yet: an empty `sessions` says nothing then. */
|
|
257
|
+
readonly sessionsPending?: true
|
|
251
258
|
}
|
|
252
259
|
|
|
253
260
|
/** A machine's terminal context: its Herdr workspaces (latest terminals frame of the machine). */
|
|
@@ -274,6 +281,8 @@ interface Conn {
|
|
|
274
281
|
sessions: Map<string, SessionInfo>
|
|
275
282
|
/** The last sessions frame as JSON, to skip unchanged snapshots. */
|
|
276
283
|
sessionsKey: string
|
|
284
|
+
/** (FX-07) No sessions frame yet. */
|
|
285
|
+
sessionsPending?: true
|
|
277
286
|
/** sessionID → activity sequence number of the last event this process sent for it. */
|
|
278
287
|
activity: Map<string, number>
|
|
279
288
|
/**
|
|
@@ -299,6 +308,8 @@ export interface SliceConn {
|
|
|
299
308
|
readonly launchID?: string
|
|
300
309
|
/** The machine's terminal context, when this connection sent the latest one. */
|
|
301
310
|
readonly terminals?: MachineTerminals
|
|
311
|
+
/** (FX-07) No sessions frame yet (absent from older pods: reported). */
|
|
312
|
+
readonly sessionsPending?: true
|
|
302
313
|
}
|
|
303
314
|
|
|
304
315
|
/** A root session's status tree (live: run starts and recommendations included) and activity feed. */
|
|
@@ -405,6 +416,7 @@ export class Registry {
|
|
|
405
416
|
private readonly sessionsListeners = new Set<(connID: string) => void>()
|
|
406
417
|
/** Called when the user's own prompt reached a session's inbox (typed or spoken in the harness). */
|
|
407
418
|
private readonly humanTurnListeners = new Set<(sessionID: string, tenant?: string) => void>()
|
|
419
|
+
private readonly sessionDeletedListeners = new Set<(sessionID: string, tenant: string) => void>()
|
|
408
420
|
/** Pending form ID → its session and every connection that forwarded it, first first (like permissions). */
|
|
409
421
|
private readonly forms = new Map<string, { connIDs: string[]; sessionID: string }>()
|
|
410
422
|
/** Form IDs already alerted (forms may be forwarded by several processes). */
|
|
@@ -450,7 +462,7 @@ export class Registry {
|
|
|
450
462
|
private remoteBoards = new Map<string, SliceBoard>()
|
|
451
463
|
private remotePermissions = new Map<string, { sessionID: string; tenant: string; connIDs: string[]; request?: PermissionRequest; recommendation?: BlockerRecommendation }>()
|
|
452
464
|
private remoteForms = new Map<string, { sessionID: string; connIDs: string[] }>()
|
|
453
|
-
private remotePresence: Array<{ sessionID: string; at: number }> = []
|
|
465
|
+
private remotePresence: Array<{ deviceID?: string; sessionID: string; at: number }> = []
|
|
454
466
|
private remoteBusy = new Set<string>()
|
|
455
467
|
private remoteTerminals = new Map<string, MachineTerminals>()
|
|
456
468
|
|
|
@@ -573,6 +585,12 @@ export class Registry {
|
|
|
573
585
|
return () => this.humanTurnListeners.delete(listener)
|
|
574
586
|
}
|
|
575
587
|
|
|
588
|
+
/** Listens to sessions a spoke reports deleted (session.deleted, seen once by the pod holding the spoke). */
|
|
589
|
+
onSessionDeleted(listener: (sessionID: string, tenant: string) => void): () => void {
|
|
590
|
+
this.sessionDeletedListeners.add(listener)
|
|
591
|
+
return () => this.sessionDeletedListeners.delete(listener)
|
|
592
|
+
}
|
|
593
|
+
|
|
576
594
|
connect(connID: string, machine: MachineRef, hello: Hello, options: ConnectOptions = {}): void {
|
|
577
595
|
const t = this.now()
|
|
578
596
|
this.conns.set(connID, {
|
|
@@ -586,6 +604,7 @@ export class Registry {
|
|
|
586
604
|
lastSeen: t,
|
|
587
605
|
sessions: new Map(),
|
|
588
606
|
sessionsKey: "[]",
|
|
607
|
+
sessionsPending: true,
|
|
589
608
|
activity: new Map(),
|
|
590
609
|
})
|
|
591
610
|
this.changed(this.tenant(this.conns.get(connID)!))
|
|
@@ -628,7 +647,9 @@ export class Registry {
|
|
|
628
647
|
conn.lastSeen = this.now()
|
|
629
648
|
// Spokes re-send their snapshot periodically; only changes reach the phones.
|
|
630
649
|
const key = JSON.stringify(sessions)
|
|
631
|
-
|
|
650
|
+
const first = conn.sessionsPending === true
|
|
651
|
+
delete conn.sessionsPending
|
|
652
|
+
if (key !== conn.sessionsKey || first) {
|
|
632
653
|
conn.sessionsKey = key
|
|
633
654
|
conn.sessions = new Map(sessions.map((s) => [s.id, s]))
|
|
634
655
|
this.syncBoards()
|
|
@@ -736,6 +757,7 @@ export class Registry {
|
|
|
736
757
|
for (const listener of [...this.humanTurnListeners]) listener(sessionID, tenant)
|
|
737
758
|
}
|
|
738
759
|
if (event.type === "session.deleted" && sessionID) this.setConversation(sessionID, false)
|
|
760
|
+
if (event.type === "session.deleted" && sessionID) for (const listener of [...this.sessionDeletedListeners]) listener(sessionID, tenant)
|
|
739
761
|
if (closedPermission) this.permissionClosed(...closedPermission)
|
|
740
762
|
}
|
|
741
763
|
|
|
@@ -1037,6 +1059,17 @@ export class Registry {
|
|
|
1037
1059
|
return false
|
|
1038
1060
|
}
|
|
1039
1061
|
|
|
1062
|
+
/**
|
|
1063
|
+
* (CON-06) True while this device reported this exact session visible within PRESENCE_WINDOW_MS (on any pod):
|
|
1064
|
+
* the voice-confirm check (a paired device with the Concierge open).
|
|
1065
|
+
*/
|
|
1066
|
+
present(deviceID: string, sessionID: string): boolean {
|
|
1067
|
+
const t = this.now()
|
|
1068
|
+
const local = this.presence.get(`${deviceID} ${sessionID}`)
|
|
1069
|
+
if (local && t - local.at <= PRESENCE_WINDOW_MS) return true
|
|
1070
|
+
return this.remotePresence.some((entry) => entry.deviceID === deviceID && entry.sessionID === sessionID && t - entry.at <= PRESENCE_WINDOW_MS)
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1040
1073
|
/** Whether any process exposes the session to voice; undefined when no process reports it. */
|
|
1041
1074
|
exposed(sessionID: string): boolean | undefined {
|
|
1042
1075
|
let reported = false
|
|
@@ -1208,6 +1241,7 @@ export class Registry {
|
|
|
1208
1241
|
features: [...conn.features].sort(),
|
|
1209
1242
|
connected: conn.connected,
|
|
1210
1243
|
lastSeen: conn.lastSeen,
|
|
1244
|
+
...(conn.sessionsPending ? { sessionsPending: true as const } : {}),
|
|
1211
1245
|
sessions: this.visible(conn).map((s) => {
|
|
1212
1246
|
const fields = this.browserFields(s.id)
|
|
1213
1247
|
return {
|
|
@@ -1225,6 +1259,19 @@ export class Registry {
|
|
|
1225
1259
|
return [...machines.values()]
|
|
1226
1260
|
}
|
|
1227
1261
|
|
|
1262
|
+
/**
|
|
1263
|
+
* (FX-07) The tenant's sessions a spoke reports locked now (the Concierge's: `locked`, from the first frame after a
|
|
1264
|
+
* restart, and `agent` / `contextRevision`, sent only for locked sessions), on this pod's connections and other pods'.
|
|
1265
|
+
*/
|
|
1266
|
+
lockedSessions(tenant: string): string[] {
|
|
1267
|
+
const out = new Set<string>()
|
|
1268
|
+
for (const conn of this.all()) {
|
|
1269
|
+
if (this.tenant(conn) !== tenant) continue
|
|
1270
|
+
for (const s of conn.sessions.values()) if (s.locked === true || s.agent !== undefined || s.contextRevision !== undefined) out.add(s.id)
|
|
1271
|
+
}
|
|
1272
|
+
return [...out]
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1228
1275
|
/** The session as reported by any process plus its live status; undefined when nobody reports it. */
|
|
1229
1276
|
session(sessionID: string): SessionView | undefined {
|
|
1230
1277
|
let info: SessionInfo | undefined
|
|
@@ -1248,6 +1295,9 @@ export class Registry {
|
|
|
1248
1295
|
...(info.projectName ? { projectName: info.projectName } : {}),
|
|
1249
1296
|
updated: info.updated,
|
|
1250
1297
|
...(terminal ? { open: true, terminal } : {}),
|
|
1298
|
+
...(info.revert ? { revert: info.revert } : {}),
|
|
1299
|
+
...(info.agent !== undefined ? { agent: info.agent } : {}),
|
|
1300
|
+
...(info.contextRevision !== undefined ? { contextRevision: info.contextRevision } : {}),
|
|
1251
1301
|
...fields,
|
|
1252
1302
|
busy: this.busy(sessionID),
|
|
1253
1303
|
conversation: this.conversations.has(sessionID),
|
|
@@ -1568,7 +1618,8 @@ export class Registry {
|
|
|
1568
1618
|
* Connection that should run a turn for the session:
|
|
1569
1619
|
* 1. a process reporting it as owned, most recent activity first;
|
|
1570
1620
|
* 2. else a service process that reports it;
|
|
1571
|
-
* 3. else the service process of a machine that reports it
|
|
1621
|
+
* 3. else the (never launch-bound) service process of a machine that reports it, of the same harness as its
|
|
1622
|
+
* reporters (never a service of another harness: a Claude Code service cannot serve an OpenCode session);
|
|
1572
1623
|
* 4. else any process that reports it, most recent activity first;
|
|
1573
1624
|
* 5. else none.
|
|
1574
1625
|
*/
|
|
@@ -1587,8 +1638,17 @@ export class Registry {
|
|
|
1587
1638
|
if (owned.length) return recent(owned)
|
|
1588
1639
|
const services = reporters.filter((c) => c.hello.kind === "service")
|
|
1589
1640
|
if (services.length) return recent(services)
|
|
1641
|
+
// A machine's service runs one harness: only a service of the session's harness (its reporters') may run it.
|
|
1590
1642
|
const machines = new Set(reporters.map((c) => c.machine.id))
|
|
1591
|
-
|
|
1643
|
+
const harnesses = new Set(reporters.map((c) => helloHarness(c.hello).harness))
|
|
1644
|
+
const machineServices = all.filter(
|
|
1645
|
+
(c) =>
|
|
1646
|
+
c.hello.kind === "service" &&
|
|
1647
|
+
c.launchID === undefined &&
|
|
1648
|
+
machines.has(c.machine.id) &&
|
|
1649
|
+
harnesses.has(helloHarness(c.hello).harness),
|
|
1650
|
+
)
|
|
1651
|
+
return recent(machineServices) ?? recent(reporters)
|
|
1592
1652
|
}
|
|
1593
1653
|
|
|
1594
1654
|
/**
|
|
@@ -1856,6 +1916,7 @@ export class Registry {
|
|
|
1856
1916
|
activity: [...conn.activity],
|
|
1857
1917
|
...(conn.launchID !== undefined ? { launchID: conn.launchID } : {}),
|
|
1858
1918
|
...(terminals?.connID === conn.id ? { terminals: terminals.terminals } : {}),
|
|
1919
|
+
...(conn.sessionsPending ? { sessionsPending: true as const } : {}),
|
|
1859
1920
|
}
|
|
1860
1921
|
})
|
|
1861
1922
|
const boards: SliceBoard[] = [...this.boards]
|
|
@@ -1910,7 +1971,7 @@ export class Registry {
|
|
|
1910
1971
|
const boards = new Map<string, SliceBoard>()
|
|
1911
1972
|
const permissions = new Map<string, { sessionID: string; tenant: string; connIDs: string[]; request?: PermissionRequest; recommendation?: BlockerRecommendation }>()
|
|
1912
1973
|
const forms = new Map<string, { sessionID: string; connIDs: string[] }>()
|
|
1913
|
-
const presence: Array<{ sessionID: string; at: number }> = []
|
|
1974
|
+
const presence: Array<{ deviceID?: string; sessionID: string; at: number }> = []
|
|
1914
1975
|
const busy = new Set<string>()
|
|
1915
1976
|
const terminals = new Map<string, MachineTerminals>()
|
|
1916
1977
|
for (const [pod, slices] of this.remoteSlices) {
|
|
@@ -1929,6 +1990,7 @@ export class Registry {
|
|
|
1929
1990
|
sessionsKey: "",
|
|
1930
1991
|
activity: new Map(c.activity ?? []),
|
|
1931
1992
|
...(c.launchID !== undefined ? { launchID: c.launchID } : {}),
|
|
1993
|
+
...(c.sessionsPending === true ? { sessionsPending: true as const } : {}),
|
|
1932
1994
|
remote: { pod, tenant },
|
|
1933
1995
|
})
|
|
1934
1996
|
if (c.terminals) terminals.set(c.machine.id, c.terminals)
|
|
@@ -1944,7 +2006,7 @@ export class Registry {
|
|
|
1944
2006
|
if (known) known.connIDs.push(...f.connIDs.filter((id) => !known.connIDs.includes(id)))
|
|
1945
2007
|
else forms.set(f.formID, { sessionID: f.sessionID, connIDs: [...f.connIDs] })
|
|
1946
2008
|
}
|
|
1947
|
-
for (const entry of slice.presence ?? []) presence.push({ sessionID: entry.sessionID, at: entry.at })
|
|
2009
|
+
for (const entry of slice.presence ?? []) presence.push({ ...(typeof entry.deviceID === "string" ? { deviceID: entry.deviceID } : {}), sessionID: entry.sessionID, at: entry.at })
|
|
1948
2010
|
for (const sessionID of slice.busy ?? []) busy.add(sessionID)
|
|
1949
2011
|
}
|
|
1950
2012
|
}
|
package/src/central/store.ts
CHANGED
|
@@ -126,6 +126,8 @@ export interface PushPrefs {
|
|
|
126
126
|
* (once per run), or a retry for a quota, free-tier or auth error. Replaces the root's "failed" for that failure.
|
|
127
127
|
*/
|
|
128
128
|
providerError: boolean
|
|
129
|
+
/** (CON-04) The Concierge needs you: its questions, permissions, Decisions, messages and spoken replies (opens /concierge). */
|
|
130
|
+
concierge: boolean
|
|
129
131
|
}
|
|
130
132
|
|
|
131
133
|
export const PUSH_PREF_KEYS = [
|
|
@@ -147,6 +149,7 @@ export const PUSH_PREF_KEYS = [
|
|
|
147
149
|
"decision",
|
|
148
150
|
"message",
|
|
149
151
|
"providerError",
|
|
152
|
+
"concierge",
|
|
150
153
|
] as const satisfies readonly (keyof PushPrefs)[]
|
|
151
154
|
|
|
152
155
|
/** Defaults: anything that needs you, all done, failed, hand-offs and spoken replies; the finer kinds are opt-in. */
|
|
@@ -170,6 +173,7 @@ export const DEFAULT_PUSH_PREFS: Readonly<PushPrefs> = Object.freeze({
|
|
|
170
173
|
decision: true,
|
|
171
174
|
message: true,
|
|
172
175
|
providerError: true,
|
|
176
|
+
concierge: true,
|
|
173
177
|
})
|
|
174
178
|
|
|
175
179
|
/** Default and allowed range of a device's activity update interval, in minutes. */
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Shared storage shapes of the team modules (HWA-33): the staffing aggregate is read by the definition store (a
|
|
2
|
+
// publish must not drop a staffed role) and owned by seats.ts.
|
|
3
|
+
import { CONTRACT_VERSION, type TeamStaffing } from "../../protocol"
|
|
4
|
+
import { AgentError } from "../agent/errors"
|
|
5
|
+
|
|
6
|
+
/** One aggregate per team (key: team ID): its staffing records, current and ended, and the staffing Idempotency-Keys. */
|
|
7
|
+
export const TEAM_SEATS = "team-seats"
|
|
8
|
+
|
|
9
|
+
export interface SeatKey {
|
|
10
|
+
readonly staffingID: string
|
|
11
|
+
readonly digest: string
|
|
12
|
+
readonly at: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface SeatsDoc {
|
|
16
|
+
readonly v: 1
|
|
17
|
+
readonly teamID: string
|
|
18
|
+
readonly seats: readonly TeamStaffing[]
|
|
19
|
+
readonly keys: Readonly<Record<string, SeatKey>>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const isObj = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
|
|
23
|
+
|
|
24
|
+
/** The stored staffing aggregate (undefined when missing); newer versions are unsupported, malformed ones internal. */
|
|
25
|
+
export function readSeatsDoc(raw: unknown, teamID: string): SeatsDoc | undefined {
|
|
26
|
+
if (raw === undefined) return undefined
|
|
27
|
+
if (isObj(raw) && typeof raw.v === "number" && raw.v > CONTRACT_VERSION)
|
|
28
|
+
throw new AgentError("unsupported", `The staffing of team ${teamID} was written by a newer hub`, { hint: "Upgrade this hub", extra: { feature: "team-staffing" } })
|
|
29
|
+
if (!isObj(raw) || raw.v !== CONTRACT_VERSION || raw.teamID !== teamID || !Array.isArray(raw.seats)) throw new AgentError("internal", `Stored staffing of team ${teamID} is malformed`)
|
|
30
|
+
return { v: 1, teamID, seats: raw.seats as TeamStaffing[], keys: isObj(raw.keys) ? (raw.keys as Record<string, SeatKey>) : {} }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Current seats (not ended). */
|
|
34
|
+
export const currentSeats = (doc: SeatsDoc | undefined): TeamStaffing[] => (doc?.seats ?? []).filter((s) => s.until === undefined)
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
// Team-aware discovery (HWA-40; contract docs/plans/2026-10-30-hub-workspaces-artifacts-contracts.md, Teams
|
|
2
|
+
// "Discovery (HWA-40)"; decisions zltiwb4s 2026-10-28 addition, r3vv4eon (+correction), 5etngvgm, qxzrdc6c).
|
|
3
|
+
//
|
|
4
|
+
// Who sees whom, for who / status / sessions / show / log / watch (with or without ?session=) and events:
|
|
5
|
+
// - A session caller holding a seat in at least one ACTIVE team (paused teams count; archived ones do not — the same
|
|
6
|
+
// rule teamDeliveryVerdict uses) is team-scoped: it sees itself, every current member of its teams (grouped by team,
|
|
7
|
+
// with each member's roles there) and, unless the owner turned `teamIsolation` on, sessions in no active team.
|
|
8
|
+
// Sessions seated only in teams the caller is not in are invisible; so are the caller's teammates' seats in teams
|
|
9
|
+
// the caller is not in (never listed). Under isolation, sessions in no team are hidden too.
|
|
10
|
+
// - Sessions in no active team, the user (console/device) and app tokens keep today's owner-wide view (r3vv4eon),
|
|
11
|
+
// whatever teamIsolation says (the frozen contract: isolation hides non-team sessions FROM TEAM MEMBERS only).
|
|
12
|
+
// They may filter by team. Without any team the answers are exactly today's.
|
|
13
|
+
// - An invisible session reads exactly like a foreign or unknown one: not_found "No session <addr>", and address
|
|
14
|
+
// resolution never counts it (no ambiguity, no candidate, no prefix match reveals it): Directory.resolve takes the
|
|
15
|
+
// visibility predicate for that.
|
|
16
|
+
// - Every census reads the teams and seats fresh (no cache), so staffing, unstaffing, replacement generations,
|
|
17
|
+
// pause/archive and the isolation setting apply to the very next read.
|
|
18
|
+
//
|
|
19
|
+
// Directions: `reach` is what the caller may send to a member (`send`) and receive from it (`receive`), each kind
|
|
20
|
+
// asked of teamDeliveryVerdict (never re-derived): per team with that team as the explicit context (the `#team`
|
|
21
|
+
// address), and per entry without one (a plain address). Visibility is not authorization: delivery is still enforced
|
|
22
|
+
// by the TeamPolicy (HWA-34) and acting by grants.
|
|
23
|
+
//
|
|
24
|
+
// Team role addresses (`@@role#team`, `@@role.project#team`) resolve here for reads (show, log, watch): the sessions
|
|
25
|
+
// currently seated in that role of that active team, restricted to what the caller may see.
|
|
26
|
+
//
|
|
27
|
+
// Cost per census: one team list (paged, ≤ TEAM_LIMITS.teamsPerUser) and one current-seat read per active team, plus
|
|
28
|
+
// the settings for team-scoped callers.
|
|
29
|
+
import { parseTeamRoleAddress, teamDeliveryVerdict, type TeamMessageKind, type TeamSeat } from "../../protocol"
|
|
30
|
+
import type { AgentActor } from "../agent/actor"
|
|
31
|
+
import type { Directory, DirectoryEntry, Resolved } from "../agent/directory"
|
|
32
|
+
import { AgentError } from "../agent/errors"
|
|
33
|
+
import type { Registry } from "../registry"
|
|
34
|
+
import type { TeamSeatStore } from "./seats"
|
|
35
|
+
import type { Team, TeamStore } from "./store"
|
|
36
|
+
|
|
37
|
+
const KINDS: readonly TeamMessageKind[] = ["msg", "ask"]
|
|
38
|
+
/** Pages of the team list read per census (teamsPerUser is 100; a page holds the list's default limit). */
|
|
39
|
+
const MAX_TEAM_PAGES = 20
|
|
40
|
+
/** Most candidates listed in an ambiguous error (as the directory). */
|
|
41
|
+
const CANDIDATES_MAX = 20
|
|
42
|
+
|
|
43
|
+
/** What the caller may send to / receive from a session, from teamDeliveryVerdict. */
|
|
44
|
+
export interface Reach {
|
|
45
|
+
readonly send: TeamMessageKind[]
|
|
46
|
+
readonly receive: TeamMessageKind[]
|
|
47
|
+
readonly direction: "two-way" | "outbound" | "inbound" | "none"
|
|
48
|
+
/** The caller can send nothing to it (a restricted path, or outside the caller's teams). */
|
|
49
|
+
readonly unreachable?: true
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** One team a listed session is seated in. */
|
|
53
|
+
export interface TeamMembership {
|
|
54
|
+
readonly teamID: string
|
|
55
|
+
readonly team: string
|
|
56
|
+
readonly roles: string[]
|
|
57
|
+
/** Team-scoped callers only, never for themselves: with this team as the explicit context. */
|
|
58
|
+
readonly reach?: Reach
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface TeamGroupMember {
|
|
62
|
+
readonly sessionID: string
|
|
63
|
+
readonly role: string
|
|
64
|
+
readonly generation: number
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A team in a discovery answer, with its listed members. */
|
|
68
|
+
export interface TeamGroup {
|
|
69
|
+
readonly teamID: string
|
|
70
|
+
readonly name: string
|
|
71
|
+
readonly title: string
|
|
72
|
+
readonly mode: "open" | "restricted"
|
|
73
|
+
readonly paused?: true
|
|
74
|
+
/** The caller's own roles there (team-scoped callers). */
|
|
75
|
+
readonly yourRoles?: string[]
|
|
76
|
+
readonly members: TeamGroupMember[]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A who entry: the directory entry plus additive team fields. */
|
|
80
|
+
export type DiscoveryEntry = DirectoryEntry & {
|
|
81
|
+
readonly harness?: string
|
|
82
|
+
readonly teams?: TeamMembership[]
|
|
83
|
+
/** Team-scoped callers only, never for themselves: what a plain address (no #team) reaches. */
|
|
84
|
+
readonly reach?: Reach
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface WhoAnswer {
|
|
88
|
+
readonly sessions: DiscoveryEntry[]
|
|
89
|
+
/** Present when any listed session is seated in a team (always for team-scoped callers). */
|
|
90
|
+
readonly teams?: TeamGroup[]
|
|
91
|
+
/** "team" when the caller sees its teams only; absent for the owner-wide view. */
|
|
92
|
+
readonly scope?: "team"
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface WhoFilter {
|
|
96
|
+
readonly project?: string
|
|
97
|
+
readonly role?: string
|
|
98
|
+
/** A team name or ID. */
|
|
99
|
+
readonly team?: string
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
interface Seat extends TeamSeat {
|
|
103
|
+
readonly generation: number
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** One read of the user's active teams and current seats, and what the caller may see. */
|
|
107
|
+
export interface Census {
|
|
108
|
+
readonly userID: string
|
|
109
|
+
/** Set when the caller is team-scoped. */
|
|
110
|
+
readonly callerID?: string
|
|
111
|
+
readonly callerTeams: ReadonlySet<string>
|
|
112
|
+
readonly isolation: boolean
|
|
113
|
+
readonly teams: ReadonlyMap<string, Team>
|
|
114
|
+
/** Current seats in active teams, per session. */
|
|
115
|
+
readonly seats: ReadonlyMap<string, readonly Seat[]>
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface TeamDiscoveryDeps {
|
|
119
|
+
readonly teams: Pick<TeamStore, "list" | "settings">
|
|
120
|
+
readonly seats: Pick<TeamSeatStore, "list">
|
|
121
|
+
readonly directory: Pick<Directory, "who" | "resolve">
|
|
122
|
+
/** Harness per reported session of the user (who entries carry it when known). */
|
|
123
|
+
readonly harnesses?: (userID: string) => ReadonlyMap<string, string>
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export const teamScoped = (census: Census): boolean => census.callerTeams.size > 0
|
|
127
|
+
|
|
128
|
+
/** Whether the census's caller may see the session (always, for the owner-wide view). */
|
|
129
|
+
export function visible(census: Census, sessionID: string): boolean {
|
|
130
|
+
if (!teamScoped(census) || sessionID === census.callerID) return true
|
|
131
|
+
const seats = census.seats.get(sessionID) ?? []
|
|
132
|
+
if (seats.some((s) => census.callerTeams.has(s.teamID))) return true
|
|
133
|
+
return !census.isolation && seats.length === 0
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const missing = (addr: string) => new AgentError("not_found", `No session ${addr}`)
|
|
137
|
+
|
|
138
|
+
/** Journal subject fields that name a session (an event naming any hidden one is hidden as a whole). */
|
|
139
|
+
const SESSION_SUBJECT_FIELDS = ["sessionID", "rootID", "senderID", "recipientID", "sourceSessionID", "parentID"] as const
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The journal events a team-scoped caller may read (unfiltered GET /events and GET /watch, HWA-28): none naming a
|
|
143
|
+
* session it cannot see, nor a team it holds no seat in. Undefined for the owner-wide view (no filtering at all).
|
|
144
|
+
*/
|
|
145
|
+
export function eventVisibility(census: Census): ((subject: Readonly<Record<string, string>>) => boolean) | undefined {
|
|
146
|
+
if (!teamScoped(census)) return undefined
|
|
147
|
+
return (subject) => {
|
|
148
|
+
if (subject.teamID !== undefined && !census.callerTeams.has(subject.teamID)) return false
|
|
149
|
+
return SESSION_SUBJECT_FIELDS.every((field) => subject[field] === undefined || visible(census, subject[field]!))
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export class TeamDiscovery {
|
|
154
|
+
constructor(private readonly deps: TeamDiscoveryDeps) {}
|
|
155
|
+
|
|
156
|
+
/** Reads the user's active teams, their current seats and the caller's scope. */
|
|
157
|
+
async census(userID: string, actor: AgentActor): Promise<Census> {
|
|
158
|
+
const teams = new Map<string, Team>()
|
|
159
|
+
let cursor: string | undefined
|
|
160
|
+
for (let page = 0; page < MAX_TEAM_PAGES; page++) {
|
|
161
|
+
const { items, cursor: next } = await this.deps.teams.list(userID, { state: "active", ...(cursor ? { cursor } : {}) })
|
|
162
|
+
for (const team of items) teams.set(team.id, team)
|
|
163
|
+
if (!next) break
|
|
164
|
+
cursor = next
|
|
165
|
+
}
|
|
166
|
+
const seats = new Map<string, Seat[]>()
|
|
167
|
+
for (const teamID of [...teams.keys()]) {
|
|
168
|
+
let current
|
|
169
|
+
try {
|
|
170
|
+
current = await this.deps.seats.list(userID, teamID, { current: true })
|
|
171
|
+
} catch {
|
|
172
|
+
teams.delete(teamID) // a seat aggregate this build cannot read takes no part (as in delivery)
|
|
173
|
+
continue
|
|
174
|
+
}
|
|
175
|
+
for (const s of current) seats.set(s.sessionID, [...(seats.get(s.sessionID) ?? []), { teamID, role: s.role, generation: s.generation }])
|
|
176
|
+
}
|
|
177
|
+
const callerID = actor.kind === "session" ? actor.sessionID : undefined
|
|
178
|
+
const callerTeams = new Set((callerID !== undefined ? (seats.get(callerID) ?? []) : []).map((s) => s.teamID))
|
|
179
|
+
let isolation = false
|
|
180
|
+
if (callerTeams.size) {
|
|
181
|
+
try {
|
|
182
|
+
isolation = (await this.deps.teams.settings(userID)).teamIsolation
|
|
183
|
+
} catch {
|
|
184
|
+
isolation = true // unreadable settings: show less, never more
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return { userID, ...(callerTeams.size ? { callerID } : {}), callerTeams, isolation, teams, seats }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The sessions an address names for the caller (Directory.resolve plus `@@role#team`), never an invisible one.
|
|
192
|
+
* not_found when nothing visible matches; ambiguous lists visible candidates only.
|
|
193
|
+
*/
|
|
194
|
+
resolve(census: Census, addr: string): Resolved {
|
|
195
|
+
const team = parseTeamRoleAddress(addr)
|
|
196
|
+
if (!team) return this.deps.directory.resolve(census.userID, addr, { visible: (id) => visible(census, id) })
|
|
197
|
+
const record = [...census.teams.values()].find((t) => t.name === team.team)
|
|
198
|
+
if (!record || (teamScoped(census) && !census.callerTeams.has(record.id))) throw missing(addr)
|
|
199
|
+
const sessions: DirectoryEntry[] = []
|
|
200
|
+
for (const [sessionID, seats] of census.seats) {
|
|
201
|
+
if (!seats.some((s) => s.teamID === record.id && s.role === team.role) || !visible(census, sessionID)) continue
|
|
202
|
+
const entry = this.entry(census.userID, sessionID)
|
|
203
|
+
if (entry && (team.project === undefined || entry.project === team.project)) sessions.push(entry)
|
|
204
|
+
}
|
|
205
|
+
if (!sessions.length) throw missing(addr)
|
|
206
|
+
return { address: { kind: "role", role: team.role, ...(team.project ? { project: team.project } : {}) }, sessions }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Exactly one visible session (a role with several visible holders is ambiguous). */
|
|
210
|
+
resolveOne(census: Census, addr: string): DirectoryEntry {
|
|
211
|
+
const { sessions } = this.resolve(census, addr)
|
|
212
|
+
if (sessions.length > 1) {
|
|
213
|
+
const candidates = sessions.slice(0, CANDIDATES_MAX).map((s) => ({ sessionID: s.sessionID, ...(s.name ? { name: s.name } : {}), ...(s.project ? { project: s.project } : {}) }))
|
|
214
|
+
throw new AgentError("ambiguous", `${addr} matches ${sessions.length} sessions`, { extra: { candidates } })
|
|
215
|
+
}
|
|
216
|
+
return sessions[0]!
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** GET /who: the caller's visible sessions, filtered, with team memberships, reach and groups. */
|
|
220
|
+
who(census: Census, filter: WhoFilter = {}): WhoAnswer {
|
|
221
|
+
const { userID } = census
|
|
222
|
+
const project = filter.project?.toLowerCase()
|
|
223
|
+
let role = filter.role?.replace(/^@@/, "")
|
|
224
|
+
let teamName = filter.team
|
|
225
|
+
if (role?.includes("#")) {
|
|
226
|
+
const parsed = parseTeamRoleAddress(`@@${role}`)
|
|
227
|
+
if (!parsed) throw new AgentError("invalid", "A role is 2–40 characters: a-z, 0-9 and -, optionally .project and #team")
|
|
228
|
+
if (teamName !== undefined && teamName !== parsed.team) throw new AgentError("invalid", "role names another team than team")
|
|
229
|
+
role = parsed.project ? `${parsed.role}.${parsed.project}` : parsed.role
|
|
230
|
+
teamName = parsed.team
|
|
231
|
+
}
|
|
232
|
+
const team = teamName !== undefined ? this.team(census, teamName) : undefined
|
|
233
|
+
const [roleName, inlineProject] = role?.split(".") ?? []
|
|
234
|
+
const roleProject = inlineProject?.toLowerCase() ?? project
|
|
235
|
+
// Global role holders (Directory.who validates the role) and the base list.
|
|
236
|
+
const globalHolders = role !== undefined ? new Set(this.deps.directory.who(userID, { ...(project ? { project } : {}), role }).map((e) => e.sessionID)) : undefined
|
|
237
|
+
const listed = new Map(this.deps.directory.who(userID, project ? { project } : {}).map((e) => [e.sessionID, e] as const))
|
|
238
|
+
// Seated members the directory does not list (offline, unnamed): every teammate, and a filtered team's members.
|
|
239
|
+
const extra = new Set(team ? [team.id] : [...census.callerTeams])
|
|
240
|
+
for (const [sessionID, seats] of census.seats) {
|
|
241
|
+
if (listed.has(sessionID) || !seats.some((s) => extra.has(s.teamID))) continue
|
|
242
|
+
const entry = this.entry(userID, sessionID)
|
|
243
|
+
if (entry && !entry.archived && (project === undefined || entry.project === project || entry.roles.some((r) => r.project === project))) listed.set(sessionID, entry)
|
|
244
|
+
}
|
|
245
|
+
const entries = [...listed.values()]
|
|
246
|
+
.filter((e) => visible(census, e.sessionID))
|
|
247
|
+
.filter((e) => !team || this.shown(census, e.sessionID).some((s) => s.teamID === team.id))
|
|
248
|
+
.filter((e) => {
|
|
249
|
+
if (role === undefined) return true
|
|
250
|
+
const seated = this.shown(census, e.sessionID).some((s) => s.role === roleName && (!team || s.teamID === team.id) && (roleProject === undefined || e.project === roleProject))
|
|
251
|
+
return seated || (!team && globalHolders!.has(e.sessionID))
|
|
252
|
+
})
|
|
253
|
+
const harnesses = this.deps.harnesses?.(userID)
|
|
254
|
+
const sessions = entries.map((e) => this.annotate(census, e, harnesses?.get(e.sessionID)))
|
|
255
|
+
const teams = this.groups(census, sessions.map((s) => s.sessionID), team?.id)
|
|
256
|
+
return { sessions, ...(teams.length ? { teams } : {}), ...(teamScoped(census) ? { scope: "team" as const } : {}) }
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Team groups of the listed sessions (the caller's teams only, all of them, when team-scoped), by team name. */
|
|
260
|
+
groups(census: Census, sessionIDs: readonly string[], only?: string): TeamGroup[] {
|
|
261
|
+
const out = new Map<string, TeamGroupMember[]>()
|
|
262
|
+
if (teamScoped(census)) for (const teamID of census.callerTeams) if (only === undefined || only === teamID) out.set(teamID, [])
|
|
263
|
+
for (const id of sessionIDs) {
|
|
264
|
+
for (const s of this.shown(census, id)) {
|
|
265
|
+
if (only !== undefined && s.teamID !== only) continue
|
|
266
|
+
out.set(s.teamID, [...(out.get(s.teamID) ?? []), { sessionID: id, role: s.role, generation: s.generation }])
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return [...out]
|
|
270
|
+
.map(([teamID, members]) => {
|
|
271
|
+
const team = census.teams.get(teamID)!
|
|
272
|
+
const yours = census.callerID !== undefined ? (census.seats.get(census.callerID) ?? []).filter((s) => s.teamID === teamID).map((s) => s.role) : []
|
|
273
|
+
return { teamID, name: team.name, title: team.title, mode: team.mode, ...(team.paused ? { paused: true as const } : {}), ...(yours.length ? { yourRoles: yours } : {}), members }
|
|
274
|
+
})
|
|
275
|
+
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** The session's team memberships as the caller may see them (show), each with reach when team-scoped. */
|
|
279
|
+
memberships(census: Census, sessionID: string): TeamMembership[] {
|
|
280
|
+
const byTeam = new Map<string, string[]>()
|
|
281
|
+
for (const s of this.shown(census, sessionID)) byTeam.set(s.teamID, [...(byTeam.get(s.teamID) ?? []), s.role])
|
|
282
|
+
return [...byTeam].map(([teamID, roles]) => {
|
|
283
|
+
const reach = this.reachOf(census, sessionID, teamID)
|
|
284
|
+
return { teamID, team: census.teams.get(teamID)!.name, roles, ...(reach ? { reach } : {}) }
|
|
285
|
+
})
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** What a team-scoped caller may send to / receive from the session (undefined otherwise, and for itself). */
|
|
289
|
+
reachOf(census: Census, sessionID: string, teamID?: string): Reach | undefined {
|
|
290
|
+
const caller = census.callerID
|
|
291
|
+
if (!teamScoped(census) || caller === undefined || sessionID === caller) return undefined
|
|
292
|
+
const mine = census.seats.get(caller) ?? []
|
|
293
|
+
const theirs = census.seats.get(sessionID) ?? []
|
|
294
|
+
const allowed = (from: readonly Seat[], to: readonly Seat[], kind: TeamMessageKind) =>
|
|
295
|
+
teamDeliveryVerdict({ kind, toUser: false, replyToPermittedAsk: false, senderSeats: from, recipientSeats: to, ...(teamID !== undefined ? { teamID } : {}), teams: census.teams }).allow
|
|
296
|
+
const send = KINDS.filter((k) => allowed(mine, theirs, k))
|
|
297
|
+
const receive = KINDS.filter((k) => allowed(theirs, mine, k))
|
|
298
|
+
const direction = send.length && receive.length ? "two-way" : send.length ? "outbound" : receive.length ? "inbound" : "none"
|
|
299
|
+
return { send, receive, direction, ...(send.length ? {} : { unreachable: true as const }) }
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ---------- internals ----------
|
|
303
|
+
|
|
304
|
+
/** The seats of a session the caller may be told about: in its own teams when team-scoped, else all. */
|
|
305
|
+
private shown(census: Census, sessionID: string): readonly Seat[] {
|
|
306
|
+
const seats = census.seats.get(sessionID) ?? []
|
|
307
|
+
return teamScoped(census) ? seats.filter((s) => census.callerTeams.has(s.teamID)) : seats
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
private annotate(census: Census, entry: DirectoryEntry, harness: string | undefined): DiscoveryEntry {
|
|
311
|
+
const teams = this.memberships(census, entry.sessionID)
|
|
312
|
+
const reach = this.reachOf(census, entry.sessionID)
|
|
313
|
+
return { ...entry, ...(harness ? { harness } : {}), ...(teams.length ? { teams } : {}), ...(reach ? { reach } : {}) }
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** A team filter: by name or ID, among the caller's teams when team-scoped (else not_found, like a missing one). */
|
|
317
|
+
private team(census: Census, nameOrID: string): Team {
|
|
318
|
+
const team = census.teams.get(nameOrID) ?? [...census.teams.values()].find((t) => t.name === nameOrID)
|
|
319
|
+
if (!team || (teamScoped(census) && !census.callerTeams.has(team.id))) throw new AgentError("not_found", `No team ${nameOrID}`)
|
|
320
|
+
return team
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
private entry(userID: string, sessionID: string): DirectoryEntry | undefined {
|
|
324
|
+
try {
|
|
325
|
+
return this.deps.directory.resolve(userID, sessionID).sessions.find((e) => e.sessionID === sessionID)
|
|
326
|
+
} catch {
|
|
327
|
+
return undefined
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** The harness of each session the user's spokes report now (roots and their subagents), from the catalog. */
|
|
333
|
+
export function catalogHarnesses(registry: Pick<Registry, "catalog" | "sessionTenant">): (userID: string) => ReadonlyMap<string, string> {
|
|
334
|
+
return (userID) => {
|
|
335
|
+
const out = new Map<string, string>()
|
|
336
|
+
for (const machine of registry.catalog()) {
|
|
337
|
+
for (const process of machine.processes) {
|
|
338
|
+
for (const s of process.sessions) {
|
|
339
|
+
if (registry.sessionTenant(s.id) !== userID) continue
|
|
340
|
+
for (const id of [s.id, ...s.status.map((e) => e.id)]) if (!out.has(id)) out.set(id, process.harness)
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return out
|
|
345
|
+
}
|
|
346
|
+
}
|