@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/migrate.ts
CHANGED
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
symlinkSync,
|
|
17
17
|
} from "node:fs"
|
|
18
18
|
import path from "node:path"
|
|
19
|
-
import type
|
|
19
|
+
import { hubEnv, type Env } from "./env"
|
|
20
20
|
|
|
21
21
|
export const APP_DIR = "hub"
|
|
22
22
|
/** Legacy directory names under ~/.config and ~/.local/state, most recent first. */
|
|
@@ -24,8 +24,81 @@ export const LEGACY_APP_DIRS: readonly string[] = Object.freeze(["opencode-hub",
|
|
|
24
24
|
|
|
25
25
|
/** $XDG_CONFIG_HOME or ~/.config. */
|
|
26
26
|
export const configBase = (env: Env, home: string): string => env.XDG_CONFIG_HOME || path.join(home, ".config")
|
|
27
|
-
/**
|
|
28
|
-
export const
|
|
27
|
+
/** $XDG_STATE_HOME when it is an absolute path (the XDG spec: a relative one is invalid and ignored), else undefined. */
|
|
28
|
+
export const xdgStateHome = (env: Env): string | undefined => (env.XDG_STATE_HOME && path.isAbsolute(env.XDG_STATE_HOME) ? env.XDG_STATE_HOME : undefined)
|
|
29
|
+
|
|
30
|
+
/** $XDG_STATE_HOME or ~/.local/state. The hub's own state dir is resolveHubStateDir (sticky), not this. */
|
|
31
|
+
export const stateBase = (env: Env, home: string): string => xdgStateHome(env) ?? path.join(home, ".local", "state")
|
|
32
|
+
|
|
33
|
+
export interface HubStateDir {
|
|
34
|
+
readonly dir: string
|
|
35
|
+
/** env: HUB_STATE_DIR; xdg: an existing $XDG_STATE_HOME/hub; legacy: an existing ~/.local/state/hub kept although
|
|
36
|
+
* XDG_STATE_HOME is set; fresh: a new $XDG_STATE_HOME/hub; default: ~/.local/state/hub without XDG_STATE_HOME. */
|
|
37
|
+
readonly source: "env" | "xdg" | "legacy" | "fresh" | "default"
|
|
38
|
+
/** legacy only: one line for the log at startup (XDG_STATE_HOME is set, the existing dir is used, how to switch). */
|
|
39
|
+
readonly hint?: string
|
|
40
|
+
/** `dir`, then the other default places (~/.local/state/hub, $XDG_STATE_HOME/hub) it is not: the Concierge lock is read from all. */
|
|
41
|
+
readonly candidates: readonly string[]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* What the hub writes in its state dir (the hub server's state.json, token-keys.json, agent/ and blobs/; the plugin's
|
|
46
|
+
* launchers bin/ and Concierge lock; the local hub's hub.log / hub.pid; the Claude Code and Codex spokes' dirs). A
|
|
47
|
+
* dir holding none of them (empty, or only `hubctl setup`'s bootstrap/) does not count as an existing state dir.
|
|
48
|
+
*/
|
|
49
|
+
export const HUB_STATE_MARKERS: readonly string[] = Object.freeze(["state.json", "token-keys.json", "agent", "blobs", "concierge-sessions.json", "bin", "hub.log", "hub.pid", "claude-code", "codex"])
|
|
50
|
+
|
|
51
|
+
/** Whether `dir` holds hub state (any of HUB_STATE_MARKERS). */
|
|
52
|
+
export const holdsHubState = (dir: string, exists: (p: string) => boolean = existsSync): boolean => HUB_STATE_MARKERS.some((marker) => exists(path.join(dir, marker)))
|
|
53
|
+
|
|
54
|
+
const shellQuote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The hub state dir, "sticky" (FX-08): $HUB_STATE_DIR; else $XDG_STATE_HOME/hub when XDG_STATE_HOME is set and that
|
|
58
|
+
* dir holds hub state (HUB_STATE_MARKERS; `exists` is asked about each marker); else an existing ~/.local/state/hub (kept, never copied or moved; with a hint when XDG_STATE_HOME is
|
|
59
|
+
* set); else $XDG_STATE_HOME/hub when it is set (fresh installs); else ~/.local/state/hub. Every part of hub (plugin,
|
|
60
|
+
* local hub, hub server, the Claude Code and Codex spokes) uses this, so a shell and a service unit agree unless one
|
|
61
|
+
* sets HUB_STATE_DIR.
|
|
62
|
+
*/
|
|
63
|
+
export function resolveHubStateDir(env: Env, home: string, exists: (p: string) => boolean = existsSync): HubStateDir {
|
|
64
|
+
const legacy = path.join(home, ".local", "state", APP_DIR)
|
|
65
|
+
const xdg = xdgStateHome(env)
|
|
66
|
+
const xdgHub = xdg ? path.join(xdg, APP_DIR) : undefined
|
|
67
|
+
const defaults = [legacy, ...(xdgHub && xdgHub !== legacy ? [xdgHub] : [])]
|
|
68
|
+
const result = (dir: string, source: HubStateDir["source"], hint?: string): HubStateDir => ({
|
|
69
|
+
dir,
|
|
70
|
+
source,
|
|
71
|
+
...(hint ? { hint } : {}),
|
|
72
|
+
candidates: [dir, ...defaults.filter((d) => d !== dir)],
|
|
73
|
+
})
|
|
74
|
+
const explicit = hubEnv(env).HUB_STATE_DIR
|
|
75
|
+
if (explicit) return result(explicit, "env")
|
|
76
|
+
const holdsState = (dir: string) => holdsHubState(dir, exists)
|
|
77
|
+
if (xdgHub && holdsState(xdgHub)) return result(xdgHub, "xdg")
|
|
78
|
+
if (holdsState(legacy)) {
|
|
79
|
+
const hint =
|
|
80
|
+
xdgHub && xdgHub !== legacy
|
|
81
|
+
? `XDG_STATE_HOME is set, but the existing ${legacy} is used as the hub state dir; to switch, stop the hub and OpenCode, then: mv ${shellQuote(legacy)} ${shellQuote(xdgHub)}`
|
|
82
|
+
: undefined
|
|
83
|
+
return result(legacy, "legacy", hint)
|
|
84
|
+
}
|
|
85
|
+
return xdgHub ? result(xdgHub, "fresh") : result(legacy, "default")
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let hinted = false
|
|
89
|
+
/** Logs the resolver's hint once per process (at startup). */
|
|
90
|
+
export function hintStateDirOnce(resolved: HubStateDir, log: (line: string) => void = console.warn): void {
|
|
91
|
+
if (!resolved.hint || hinted) return
|
|
92
|
+
hinted = true
|
|
93
|
+
log(`[hub] ${resolved.hint}`)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The legacy (opencode-hub, opencode-voice) state dirs, most recent first: under $XDG_STATE_HOME, then ~/.local/state. */
|
|
97
|
+
export function stateLegacyPaths(env: Env, home: string): string[] {
|
|
98
|
+
const xdg = xdgStateHome(env)
|
|
99
|
+
const bases = [...(xdg ? [xdg] : []), path.join(home, ".local", "state")]
|
|
100
|
+
return [...new Set(bases.flatMap((base) => legacyPaths(base)))]
|
|
101
|
+
}
|
|
29
102
|
|
|
30
103
|
export type MigrationResult = "migrated" | "none" | "failed"
|
|
31
104
|
|
|
@@ -103,3 +176,36 @@ export function migrateFirst(legacies: readonly string[], target: string, option
|
|
|
103
176
|
|
|
104
177
|
/** The legacy copies of `<base>/<APP_DIR>/<...rest>`, most recent first. */
|
|
105
178
|
export const legacyPaths = (base: string, ...rest: string[]): string[] => LEGACY_APP_DIRS.map((dir) => path.join(base, dir, ...rest))
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* migrateFirst for the hub state dir: a target that holds no hub state (missing, empty, or only `hubctl setup`'s
|
|
182
|
+
* bootstrap/) counts as missing. The newest existing legacy dir is copied through a temporary sibling, then each of
|
|
183
|
+
* its top-level entries is renamed into the target unless the target already has one by that name (bootstrap/ is
|
|
184
|
+
* never overwritten). Never throws; the legacy copies are never touched.
|
|
185
|
+
*/
|
|
186
|
+
export function migrateStateDir(legacies: readonly string[], target: string, options: MigrateOptions = {}): MigrationResult {
|
|
187
|
+
if (!exists(target)) return migrateFirst(legacies, target, options)
|
|
188
|
+
if (holdsHubState(target, exists)) return "none"
|
|
189
|
+
const legacy = legacies.find(exists)
|
|
190
|
+
if (legacy === undefined) return "none"
|
|
191
|
+
const tmp = `${target}.migrating-${process.pid}-${Date.now()}`
|
|
192
|
+
let moved = 0
|
|
193
|
+
try {
|
|
194
|
+
const present = new Set(readdirSync(target))
|
|
195
|
+
copyEntry(legacy, tmp, [...(options.skip ?? []), ...present])
|
|
196
|
+
for (const name of readdirSync(tmp)) {
|
|
197
|
+
if (exists(path.join(target, name))) continue
|
|
198
|
+
renameSync(path.join(tmp, name), path.join(target, name))
|
|
199
|
+
moved++
|
|
200
|
+
}
|
|
201
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
202
|
+
} catch (cause) {
|
|
203
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
204
|
+
;(options.log ?? ((m: string) => console.warn(`[hub] warning: ${m}`)))(`could not migrate ${legacy} to ${target}: ${cause instanceof Error ? cause.message : cause}`)
|
|
205
|
+
return "failed"
|
|
206
|
+
}
|
|
207
|
+
// Everything was there already (an earlier migration of a legacy dir without hub-state markers): nothing to say.
|
|
208
|
+
if (moved === 0) return "none"
|
|
209
|
+
;(options.log ?? ((m: string) => console.log(`[hub] ${m}`)))(`migrated ${legacy} to ${target} (the project is now Hub; the old copy is kept)`)
|
|
210
|
+
return "migrated"
|
|
211
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// CON-04 the OpenCode agent the Concierge session runs on (contract "Agent"): registered by the plugin like the voice
|
|
2
|
+
// agent (conversation.ts defineVoiceAgent), mode primary, hidden. Its rules are an ALLOW-LIST: deny everything
|
|
3
|
+
// ("*"), then allow exactly CONCIERGE_TOOLS (hub, read, glob, grep, question). OpenCode evaluates the last matching
|
|
4
|
+
// rule, so nothing else (subagent/task, execute, webfetch/websearch, shell, edit/write/patch, todo, skill,
|
|
5
|
+
// external_directory, MCP or plugin tools, future tools) is allowed, even with no hub context. The hub also strips
|
|
6
|
+
// every tool off that list from each turn (sessionContext tools.allow): defence in depth. The default agent's rules
|
|
7
|
+
// are not inherited. Its instructions come from the hub with every turn (sessionContext), not from here.
|
|
8
|
+
import { CONCIERGE_AGENT, CONCIERGE_TOOLS } from "../../packages/protocol/src/concierge.ts"
|
|
9
|
+
import type { AgentEditorLike } from "./conversation"
|
|
10
|
+
|
|
11
|
+
export const CONCIERGE_AGENT_NAME = "Concierge"
|
|
12
|
+
export const CONCIERGE_AGENT_DESCRIPTION = "Hub Concierge: watches your sessions and acts only inside your grant. Hub-managed; not for direct use."
|
|
13
|
+
const CONCIERGE_SYSTEM = "You are the hub's Concierge. Your instructions, situation board and notes arrive from the hub with every turn."
|
|
14
|
+
|
|
15
|
+
export const CONCIERGE_RULES: ReadonlyArray<{ readonly action: string; readonly resource: string; readonly effect: "allow" | "deny" }> = [
|
|
16
|
+
{ action: "*", resource: "*", effect: "deny" },
|
|
17
|
+
...CONCIERGE_TOOLS.map((action) => ({ action, resource: "*", effect: "allow" as const })),
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
/** Adds (or refreshes) the hub's "concierge" agent; a "concierge" agent the user defined is left alone. */
|
|
21
|
+
export function defineConciergeAgent(editor: AgentEditorLike): void {
|
|
22
|
+
// The description marks the hub's own agent, which is refreshed; a user's own "concierge" agent is left alone.
|
|
23
|
+
const existing = editor.get(CONCIERGE_AGENT)
|
|
24
|
+
if (existing && existing.description !== CONCIERGE_AGENT_DESCRIPTION) return
|
|
25
|
+
editor.update(CONCIERGE_AGENT, (agent) => {
|
|
26
|
+
agent.name = CONCIERGE_AGENT_NAME
|
|
27
|
+
agent.description = CONCIERGE_AGENT_DESCRIPTION
|
|
28
|
+
agent.mode = "primary"
|
|
29
|
+
agent.hidden = true
|
|
30
|
+
agent.system = CONCIERGE_SYSTEM
|
|
31
|
+
agent.permissions = CONCIERGE_RULES.map((r) => ({ ...r }))
|
|
32
|
+
})
|
|
33
|
+
}
|
package/src/spoke/deliver.ts
CHANGED
|
@@ -34,13 +34,17 @@ export interface RecipientState {
|
|
|
34
34
|
* (quirk hzmcqwpj), a steered one joins them in one call. With a form open OpenCode holds the message until the
|
|
35
35
|
* form is answered ("retained").
|
|
36
36
|
*
|
|
37
|
-
* `wake:false` and subagent sessions are admitted without starting a turn ("queued")
|
|
38
|
-
* queued item gets its own model call when a turn ends (that would wake
|
|
39
|
-
* without resume waits for the next turn and arrives before its prompt
|
|
37
|
+
* `wake:false` and subagent sessions are admitted without starting a turn ("queued"), always steered (also over
|
|
38
|
+
* the hub's "queue", RF-05): a queued item gets its own model call when a turn ends, resume or not (that would wake
|
|
39
|
+
* the session), while a steered one without resume waits for the next turn and arrives before its prompt. A
|
|
40
|
+
* steered one admitted into a running turn costs a model call when the turn would have ended, so routine ones are
|
|
41
|
+
* refused while the session is busy (deferredWhileBusy).
|
|
40
42
|
*/
|
|
41
43
|
export function planDelivery(req: { readonly wake: boolean; readonly meta: DeliverMeta }, state: RecipientState): DeliveryPlan {
|
|
42
44
|
const resume = req.wake && !state.subagent
|
|
43
|
-
|
|
45
|
+
// Not waking: always steer. OpenCode gives a queued item its own model call once a turn ends (resume or not),
|
|
46
|
+
// so a hub-chosen "queue" would wake the session after all; a steered one waits for the next turn.
|
|
47
|
+
const chosen = !resume ? "steer" : (req.meta.delivery ?? (req.meta.kind === "ask" ? "steer" : "queue"))
|
|
44
48
|
// Idle: no running turn to queue behind; steered, the wake shares its model call with what already waits.
|
|
45
49
|
const delivery = resume && !state.busy && !state.formOpen ? "steer" : chosen
|
|
46
50
|
return { delivery, resume, delivered: deliveredAs(delivery, resume, state) }
|
|
@@ -71,5 +75,19 @@ export function deliverMetadata(req: { readonly messageID: string; readonly meta
|
|
|
71
75
|
return { source: PROMPT_SOURCE, messageID: req.messageID, kind, from, to, ...(replyTo !== undefined ? { replyTo } : {}), ...(grant !== undefined ? { grant } : {}) }
|
|
72
76
|
}
|
|
73
77
|
|
|
78
|
+
/** The refusal of a routine quiet message to a busy session (the hub keeps it pending and retries). */
|
|
79
|
+
export const QUIET_WHILE_BUSY = "The session is busy: a message that does not wake it is admitted after the running turn"
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Whether a message waits for the running turn to end (refused while busy, RF-05): a routine `wake:false` msg (not
|
|
83
|
+
* urgent: the hub sends routine msgs with delivery "queue") to a top-level session. OpenCode adds anything admitted
|
|
84
|
+
* into a running turn with a model call of its own when the turn would have ended. Urgent messages, asks, replies,
|
|
85
|
+
* primers and anything for a subagent (never woken; it may not be idle before its parent needs the answer) go in at
|
|
86
|
+
* once and accept that call.
|
|
87
|
+
*/
|
|
88
|
+
export function deferredWhileBusy(req: { readonly wake: boolean; readonly meta: DeliverMeta }, subagent: boolean): boolean {
|
|
89
|
+
return !req.wake && !subagent && req.meta.kind === "msg" && req.meta.delivery !== "steer"
|
|
90
|
+
}
|
|
91
|
+
|
|
74
92
|
// The prompt envelope, InboxOrigins and DeliveryLedger live in the spoke SDK (harness-neutral).
|
|
75
93
|
export { deliverText, DeliveryLedger, InboxOrigins, renderPromptEnvelope, type Admission } from "../../packages/spoke-sdk/src/delivery.ts"
|
package/src/spoke/env-inject.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
// they get only the stripped env and PATH, no token.
|
|
15
15
|
import { homedir } from "node:os"
|
|
16
16
|
import path from "node:path"
|
|
17
|
+
import { hubEnv } from "../env"
|
|
17
18
|
import { defaultStateDir } from "./localhub"
|
|
18
19
|
|
|
19
20
|
type Env = Record<string, string | undefined>
|
|
@@ -124,11 +125,17 @@ export function applyHubEnv(env: Env, identity: Identity | undefined, binDir: st
|
|
|
124
125
|
env.PATH = [binDir, ...rest].join(path.delimiter)
|
|
125
126
|
}
|
|
126
127
|
|
|
127
|
-
/** Where the `hub`/`hubctl` launchers live: bin under the hub state dir ($HUB_STATE_DIR
|
|
128
|
+
/** Where the `hub`/`hubctl` launchers live: bin under the hub state dir ($HUB_STATE_DIR, else $XDG_STATE_HOME/hub, else ~/.local/state/hub). */
|
|
128
129
|
export function hubBinDir(env: Env = process.env, home: string = homedir()): string {
|
|
129
130
|
return path.join(defaultStateDir(env, home), "bin")
|
|
130
131
|
}
|
|
131
132
|
|
|
133
|
+
/** Where ~/.local/bin/hub may point: the default (resolved) hub bin dir; none with HUB_STATE_DIR (a custom state). */
|
|
134
|
+
export function defaultHubBinDir(env: Env = process.env, home: string = homedir()): string | undefined {
|
|
135
|
+
if (hubEnv(env).HUB_STATE_DIR) return undefined
|
|
136
|
+
return path.join(defaultStateDir(env, home), "bin")
|
|
137
|
+
}
|
|
138
|
+
|
|
132
139
|
/** The hook handlers: tool execute.before/after and shell create.before. They never throw. */
|
|
133
140
|
export function envInjector(options: { correlator: ShellCorrelator; binDir: string; identity: (sessionID: string) => Identity | undefined }) {
|
|
134
141
|
const { correlator, binDir, identity } = options
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Fork and revert (features "fork" and "revert"): the pure parts of the spoke's fork, revertPreview, revert,
|
|
2
|
+
// revertRestore and revertCommit ops. OpenCode semantics (checked against `opencode serve` 2.2.3, see
|
|
3
|
+
// test/spoke-fork-revert.it.test.ts):
|
|
4
|
+
// - session.fork({sessionID, before}) copies the conversation BEFORE `before` into a new root session (allowed
|
|
5
|
+
// while the source runs).
|
|
6
|
+
// - session.revert.stage({sessionID, messageID, files: true}) marks the session reverted from that message and
|
|
7
|
+
// restores the files the session changed since then; OpenCode refuses it while the session runs
|
|
8
|
+
// (SessionBusyError). The messages stay in the session's history (message list and context): the CLIENT hides
|
|
9
|
+
// them, from the reverted message on. The session's `revert` field and session.revert.staged carry it.
|
|
10
|
+
// - session.revert.clear brings the files back (session.revert.cleared); session.revert.commit removes the hidden
|
|
11
|
+
// messages (session.revert.committed). The next prompt commits a staged revert by itself.
|
|
12
|
+
import { REVERT_FILE_MAX_CHARS, REVERT_FILES_MAX, type MessageAttachment, type RevertFile, type SessionRevertInfo } from "../protocol"
|
|
13
|
+
|
|
14
|
+
const record = (value: unknown): Record<string, any> => (value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, any>) : {})
|
|
15
|
+
const STATUS = new Set(["added", "deleted", "modified"])
|
|
16
|
+
const count = (value: unknown): number | undefined => (Number.isSafeInteger(value) && (value as number) >= 0 ? (value as number) : undefined)
|
|
17
|
+
|
|
18
|
+
export const NOT_A_USER_MESSAGE = "Not a user message of this session"
|
|
19
|
+
export const REVERT_BUSY = "The session is working; wait for it to finish (or stop it) before reverting"
|
|
20
|
+
|
|
21
|
+
/** Epoch ms of an OpenCode timestamp (number or ISO string). */
|
|
22
|
+
function millis(value: unknown): number | undefined {
|
|
23
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : undefined
|
|
24
|
+
if (typeof value !== "string") return undefined
|
|
25
|
+
const ms = Date.parse(value)
|
|
26
|
+
return Number.isNaN(ms) ? undefined : ms
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The index of the user message `messageID` in the session's messages (oldest first), or -1. */
|
|
30
|
+
export function userIndex(messages: readonly unknown[], messageID: string): number {
|
|
31
|
+
return messages.findIndex((raw) => {
|
|
32
|
+
const m = record(raw)
|
|
33
|
+
return m.id === messageID && m.type === "user"
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The text and attachments (metadata, never bytes) of a user message, for the composer. */
|
|
38
|
+
export function userContent(message: unknown): { text: string; attachments: MessageAttachment[] } {
|
|
39
|
+
const m = record(message)
|
|
40
|
+
const text =
|
|
41
|
+
typeof m.text === "string"
|
|
42
|
+
? m.text
|
|
43
|
+
: Array.isArray(m.content)
|
|
44
|
+
? m.content.filter((p: any) => p?.type === "text" && typeof p.text === "string").map((p: any) => p.text as string).join("")
|
|
45
|
+
: ""
|
|
46
|
+
const attachments: MessageAttachment[] = []
|
|
47
|
+
if (Array.isArray(m.files)) {
|
|
48
|
+
m.files.forEach((raw: unknown, index: number) => {
|
|
49
|
+
const f = record(raw)
|
|
50
|
+
if (typeof f.mime !== "string") return
|
|
51
|
+
attachments.push({ index, mime: f.mime, ...(typeof f.name === "string" && f.name ? { name: f.name.slice(0, 200) } : {}) })
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
return { text, attachments }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** User messages from `index` on (the reverted one and those after it). */
|
|
58
|
+
function usersFrom(messages: readonly unknown[], index: number): number {
|
|
59
|
+
let n = 0
|
|
60
|
+
for (let i = Math.max(0, index); i < messages.length; i++) if (record(messages[i]).type === "user") n++
|
|
61
|
+
return n
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** At most REVERT_FILES_MAX files, unique by path, with the total. */
|
|
65
|
+
function bounded(files: readonly RevertFile[]): { files: RevertFile[]; filesTotal: number } {
|
|
66
|
+
const seen = new Set<string>()
|
|
67
|
+
const unique = files.filter((f) => {
|
|
68
|
+
if (!f.file || f.file.length > REVERT_FILE_MAX_CHARS || seen.has(f.file)) return false
|
|
69
|
+
seen.add(f.file)
|
|
70
|
+
return true
|
|
71
|
+
})
|
|
72
|
+
return { files: unique.slice(0, REVERT_FILES_MAX), filesTotal: unique.length }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** OpenCode's FileDiffInfo list as revert files (no patches). */
|
|
76
|
+
function diffFiles(raw: unknown): RevertFile[] {
|
|
77
|
+
if (!Array.isArray(raw)) return []
|
|
78
|
+
return raw.flatMap((entry) => {
|
|
79
|
+
const f = record(entry)
|
|
80
|
+
if (typeof f.file !== "string" || !f.file) return []
|
|
81
|
+
const additions = count(f.additions)
|
|
82
|
+
const deletions = count(f.deletions)
|
|
83
|
+
return [
|
|
84
|
+
{
|
|
85
|
+
file: f.file,
|
|
86
|
+
...(STATUS.has(f.status) ? { status: f.status as RevertFile["status"] } : {}),
|
|
87
|
+
...(additions === undefined ? {} : { additions }),
|
|
88
|
+
...(deletions === undefined ? {} : { deletions }),
|
|
89
|
+
},
|
|
90
|
+
]
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* What reverting from the user message at `index` would hide, and the files the session's assistant messages
|
|
96
|
+
* changed from there on (their snapshots' file lists): what a revert with files restores.
|
|
97
|
+
*/
|
|
98
|
+
export function revertPreview(messages: readonly unknown[], index: number): { hidden: number; files: RevertFile[]; filesTotal: number } {
|
|
99
|
+
const files: RevertFile[] = []
|
|
100
|
+
for (let i = Math.max(0, index); i < messages.length; i++) {
|
|
101
|
+
const m = record(messages[i])
|
|
102
|
+
if (m.type !== "assistant") continue
|
|
103
|
+
for (const file of Array.isArray(m.snapshot?.files) ? m.snapshot.files : []) if (typeof file === "string" && file) files.push({ file })
|
|
104
|
+
}
|
|
105
|
+
return { hidden: usersFrom(messages, index), ...bounded(files) }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The summary of OpenCode's staged revert (session.revert, or session.revert.staged's data.revert) over the
|
|
110
|
+
* session's messages; undefined when nothing is staged.
|
|
111
|
+
*/
|
|
112
|
+
export function revertSummary(raw: unknown, messages: readonly unknown[]): SessionRevertInfo | undefined {
|
|
113
|
+
const revert = record(raw)
|
|
114
|
+
if (typeof revert.messageID !== "string" || !revert.messageID || revert.messageID.length > 300) return undefined
|
|
115
|
+
const index = messages.findIndex((m) => record(m).id === revert.messageID)
|
|
116
|
+
const at = index < 0 ? undefined : millis(record(messages[index]).time?.created)
|
|
117
|
+
return {
|
|
118
|
+
messageID: revert.messageID,
|
|
119
|
+
hidden: index < 0 ? 0 : usersFrom(messages, index),
|
|
120
|
+
...(at === undefined ? {} : { at }),
|
|
121
|
+
...bounded(diffFiles(revert.files)),
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The messages a client shows while a revert is staged from `messageID`: those before it (all when unknown). */
|
|
126
|
+
export function visibleMessages<T>(messages: readonly T[], messageID: string | undefined): readonly T[] {
|
|
127
|
+
if (!messageID) return messages
|
|
128
|
+
const index = messages.findIndex((m) => record(m).id === messageID)
|
|
129
|
+
return index < 0 ? messages : messages.slice(0, index)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Whether an OpenCode error says the session is busy (a revert refused while it runs). */
|
|
133
|
+
export function busyError(cause: unknown): boolean {
|
|
134
|
+
const c = record(cause)
|
|
135
|
+
const text = `${c._tag ?? ""} ${c.name ?? ""} ${cause instanceof Error ? cause.message : String(cause ?? "")}`
|
|
136
|
+
return /SessionBusy|Session is busy/i.test(text)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Same revert summaries (so an unchanged one sends no snapshot). */
|
|
140
|
+
export function sameRevert(a: SessionRevertInfo | undefined, b: SessionRevertInfo | undefined): boolean {
|
|
141
|
+
return JSON.stringify(a ?? null) === JSON.stringify(b ?? null)
|
|
142
|
+
}
|
package/src/spoke/hub-rpc.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// result or an error (redacted should the hub echo it). Labels are sanitised here, so the TUI shows them as is.
|
|
5
5
|
import { HubClient, HubError, HubTransportError, sanitizeLabel, type AgentMessage, type DirectoryEntry } from "../../packages/sdk/src/index.ts"
|
|
6
6
|
import type { HubToolSpoke } from "./hub-tool"
|
|
7
|
+
import type { WhoReach, WhoTeamGroup, WhoTeamRef } from "./who-teams"
|
|
7
8
|
|
|
8
9
|
/** The RPC error a failure answers with (src/rpc.ts lists them). */
|
|
9
10
|
export type HubRpcErrorCode = "invalid" | "not_connected" | "unsupported" | "unreachable" | "server_error"
|
|
@@ -25,6 +26,19 @@ export interface WhoRow {
|
|
|
25
26
|
readonly state?: string
|
|
26
27
|
readonly online: boolean
|
|
27
28
|
readonly roles: string[]
|
|
29
|
+
/** HWA-40 (additive): where it runs, its teams (roles there, the caller's reach) and whether it is the caller. */
|
|
30
|
+
readonly machineID?: string
|
|
31
|
+
readonly harness?: string
|
|
32
|
+
readonly teams?: WhoTeamRef[]
|
|
33
|
+
readonly reach?: WhoReach
|
|
34
|
+
readonly self?: true
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** /who's answer: the rows, and the team groups when the hub sent them (HWA-40). */
|
|
38
|
+
export interface WhoResult {
|
|
39
|
+
readonly sessions: WhoRow[]
|
|
40
|
+
readonly teams?: WhoTeamGroup[]
|
|
41
|
+
readonly scope?: "team"
|
|
28
42
|
}
|
|
29
43
|
|
|
30
44
|
export interface MsgResult {
|
|
@@ -57,16 +71,23 @@ export async function hubMsg(spoke: HubToolSpoke, value: unknown): Promise<MsgRe
|
|
|
57
71
|
})
|
|
58
72
|
}
|
|
59
73
|
|
|
60
|
-
/**
|
|
61
|
-
|
|
74
|
+
/**
|
|
75
|
+
* /who: the named and role-holding sessions (optionally one project, role or team), as session `sessionID`. A bare
|
|
76
|
+
* `project` that names one of the caller's teams (listed in a team member's answer) is taken as that team (/who <team>).
|
|
77
|
+
*/
|
|
78
|
+
export async function hubWho(spoke: HubToolSpoke, value: unknown): Promise<WhoResult> {
|
|
62
79
|
const input = inputOf(value)
|
|
63
80
|
const sessionID = optStr(input, "sessionID")
|
|
64
81
|
if (!sessionID) throw new HubRpcError("invalid", "Open a session first: /who asks the hub as the current session.")
|
|
65
82
|
const project = optStr(input, "project")
|
|
66
83
|
const role = optStr(input, "role")
|
|
84
|
+
const team = optStr(input, "team")
|
|
67
85
|
return withClient(spoke, sessionID, undefined, async (client, signal) => {
|
|
68
|
-
|
|
69
|
-
|
|
86
|
+
let answer = await client.who({ ...(project ? { project } : {}), ...(role ? { role } : {}), ...(team ? { team } : {}), signal })
|
|
87
|
+
// A team member's answer lists all its teams; a bare word naming one of them is /who <team>.
|
|
88
|
+
if (project && !role && !team && answer.teams?.some((t) => t.name === project)) answer = await client.who({ team: project, signal })
|
|
89
|
+
const teams = (answer.teams ?? []).map((t) => ({ name: sanitizeLabel(t.name), mode: t.mode, ...(t.paused ? { paused: true } : {}), ...(t.yourRoles?.length ? { yourRoles: t.yourRoles.map((r) => sanitizeLabel(r)) } : {}), members: t.members.map((m) => ({ sessionID: sanitizeLabel(m.sessionID), role: sanitizeLabel(m.role) })) }))
|
|
90
|
+
return { sessions: answer.sessions.map((e) => whoRow(e, sessionID)), ...(teams.length ? { teams } : {}), ...(answer.scope === "team" ? { scope: "team" as const } : {}) }
|
|
70
91
|
})
|
|
71
92
|
}
|
|
72
93
|
|
|
@@ -93,10 +114,24 @@ async function withClient<T>(
|
|
|
93
114
|
}
|
|
94
115
|
}
|
|
95
116
|
|
|
96
|
-
|
|
117
|
+
const reachRow = (r: DirectoryEntry["reach"]): WhoReach | undefined => (r ? { send: [...r.send], receive: [...r.receive], direction: r.direction, ...(r.unreachable ? { unreachable: true } : {}) } : undefined)
|
|
118
|
+
|
|
119
|
+
function whoRow(entry: DirectoryEntry, caller?: string): WhoRow {
|
|
97
120
|
const name = entry.name ? sanitizeLabel(entry.name) : ""
|
|
98
121
|
const project = entry.project ? sanitizeLabel(entry.project) : ""
|
|
99
122
|
const state = entry.state ? sanitizeLabel(entry.state) : ""
|
|
123
|
+
const teams = (entry.teams ?? []).map((t) => {
|
|
124
|
+
const reach = reachRow(t.reach)
|
|
125
|
+
return { team: sanitizeLabel(t.team), roles: t.roles.map((r) => sanitizeLabel(r)), ...(reach ? { reach } : {}) }
|
|
126
|
+
})
|
|
127
|
+
const reach = reachRow(entry.reach)
|
|
128
|
+
const extra = {
|
|
129
|
+
...(entry.machineID ? { machineID: sanitizeLabel(entry.machineID) } : {}),
|
|
130
|
+
...(entry.harness ? { harness: sanitizeLabel(entry.harness) } : {}),
|
|
131
|
+
...(teams.length ? { teams } : {}),
|
|
132
|
+
...(reach ? { reach } : {}),
|
|
133
|
+
...(caller !== undefined && entry.sessionID === caller && teams.length ? { self: true as const } : {}),
|
|
134
|
+
}
|
|
100
135
|
return {
|
|
101
136
|
sessionID: sanitizeLabel(entry.sessionID),
|
|
102
137
|
...(name ? { name } : {}),
|
|
@@ -104,6 +139,7 @@ function whoRow(entry: DirectoryEntry): WhoRow {
|
|
|
104
139
|
...(state ? { state } : {}),
|
|
105
140
|
online: entry.online === true,
|
|
106
141
|
roles: (entry.roles ?? []).map((r) => sanitizeLabel(r.role)).filter(Boolean),
|
|
142
|
+
...extra,
|
|
107
143
|
}
|
|
108
144
|
}
|
|
109
145
|
|