@huynle/hub 0.6.0 → 0.7.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.
Files changed (219) hide show
  1. package/CHANGELOG.md +800 -0
  2. package/Dockerfile +30 -0
  3. package/README.md +870 -66
  4. package/package.json +45 -2
  5. package/packages/claude-code/package.json +12 -0
  6. package/packages/claude-code/src/bridge.ts +79 -0
  7. package/packages/claude-code/src/daemon.ts +339 -0
  8. package/packages/claude-code/src/hook-input.ts +44 -0
  9. package/packages/claude-code/src/ids.ts +27 -0
  10. package/packages/claude-code/src/index.ts +18 -0
  11. package/packages/claude-code/src/launcher.ts +265 -0
  12. package/packages/claude-code/src/main.ts +127 -0
  13. package/packages/claude-code/src/mcp.ts +109 -0
  14. package/packages/claude-code/src/paths.ts +34 -0
  15. package/packages/claude-code/src/plugin.ts +99 -0
  16. package/packages/claude-code/src/references.ts +51 -0
  17. package/packages/claude-code/src/sessions.ts +600 -0
  18. package/packages/claude-code/src/tools.ts +66 -0
  19. package/packages/claude-code/src/wire.ts +71 -0
  20. package/packages/claude-code/src/wrapper.ts +155 -0
  21. package/packages/cli/dist/hub-cli.js +10162 -0
  22. package/packages/codex/package.json +12 -0
  23. package/packages/codex/src/app-server.ts +44 -0
  24. package/packages/codex/src/ids.ts +28 -0
  25. package/packages/codex/src/index.ts +11 -0
  26. package/packages/codex/src/jsonrpc.ts +168 -0
  27. package/packages/codex/src/main.ts +122 -0
  28. package/packages/codex/src/paths.ts +65 -0
  29. package/packages/codex/src/sessions.ts +578 -0
  30. package/packages/codex/src/spoke.ts +249 -0
  31. package/packages/codex/src/stdio.ts +101 -0
  32. package/packages/codex/src/websocket.ts +206 -0
  33. package/packages/codex/src/wrapper.ts +100 -0
  34. package/packages/node/package.json +12 -0
  35. package/packages/node/src/config.ts +184 -0
  36. package/packages/node/src/connection.ts +283 -0
  37. package/packages/node/src/daemon.ts +281 -0
  38. package/packages/node/src/git.ts +107 -0
  39. package/packages/node/src/harnesses.ts +60 -0
  40. package/packages/node/src/host.ts +319 -0
  41. package/packages/node/src/index.ts +17 -0
  42. package/packages/node/src/main.ts +11 -0
  43. package/packages/node/src/openshell.ts +73 -0
  44. package/packages/node/src/run.ts +106 -0
  45. package/packages/preset/package.json +12 -0
  46. package/packages/preset/presets/aider.json +8 -0
  47. package/packages/preset/presets/goose.json +8 -0
  48. package/packages/preset/src/index.ts +2 -0
  49. package/packages/preset/src/main.ts +195 -0
  50. package/packages/preset/src/presets.ts +87 -0
  51. package/packages/preset/src/spoke.ts +251 -0
  52. package/packages/preset/src/tmux.ts +160 -0
  53. package/packages/protocol/package.json +12 -0
  54. package/packages/protocol/src/harness-events.ts +207 -0
  55. package/packages/protocol/src/index.ts +1215 -0
  56. package/packages/protocol/src/launch.ts +172 -0
  57. package/packages/protocol/src/node.ts +409 -0
  58. package/packages/protocol/src/preset.ts +144 -0
  59. package/packages/protocol/src/webhook.ts +62 -0
  60. package/packages/protocol/src/wire.ts +54 -0
  61. package/packages/runtime-openshell/image/Dockerfile +36 -0
  62. package/packages/runtime-openshell/image/README.md +78 -0
  63. package/packages/runtime-openshell/image/build.ts +203 -0
  64. package/packages/runtime-openshell/image/entry.sh +163 -0
  65. package/packages/runtime-openshell/image/image.ts +119 -0
  66. package/packages/runtime-openshell/image/start.sh +24 -0
  67. package/packages/runtime-openshell/package.json +12 -0
  68. package/packages/runtime-openshell/src/cli.ts +584 -0
  69. package/packages/runtime-openshell/src/index.ts +6 -0
  70. package/packages/runtime-openshell/src/policy.ts +155 -0
  71. package/packages/runtime-openshell/src/profiles.ts +181 -0
  72. package/packages/runtime-openshell/src/runtime.ts +475 -0
  73. package/packages/runtime-sdk/package.json +12 -0
  74. package/packages/runtime-sdk/src/index.ts +87 -0
  75. package/packages/sdk/package.json +12 -0
  76. package/packages/sdk/src/address.ts +25 -0
  77. package/packages/sdk/src/client.ts +502 -0
  78. package/packages/sdk/src/envelope.ts +108 -0
  79. package/packages/sdk/src/errors.ts +106 -0
  80. package/packages/sdk/src/index.ts +10 -0
  81. package/packages/sdk/src/sse.ts +67 -0
  82. package/packages/sdk/src/types.ts +458 -0
  83. package/packages/sdk/src/webhook.ts +102 -0
  84. package/packages/spoke-sdk/package.json +12 -0
  85. package/packages/spoke-sdk/src/activity.ts +3 -0
  86. package/packages/spoke-sdk/src/connection.ts +295 -0
  87. package/packages/spoke-sdk/src/delivery.ts +119 -0
  88. package/packages/spoke-sdk/src/enroll.ts +79 -0
  89. package/packages/spoke-sdk/src/harness-hello.ts +79 -0
  90. package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
  91. package/packages/spoke-sdk/src/index.ts +12 -0
  92. package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
  93. package/packages/spoke-sdk/src/prompt-source.ts +8 -0
  94. package/packages/spoke-sdk/src/session-ids.ts +49 -0
  95. package/packages/spoke-sdk/src/session-tokens.ts +78 -0
  96. package/scripts/hub-claude-code.service +34 -0
  97. package/scripts/hub-codex-spoke.service +34 -0
  98. package/scripts/hub-node.service +35 -0
  99. package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
  100. package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
  101. package/scripts/io.github.huynle.hub-node.plist +52 -0
  102. package/src/central/actions.ts +233 -0
  103. package/src/central/agent/acting-routes.ts +133 -0
  104. package/src/central/agent/acting-switch.ts +93 -0
  105. package/src/central/agent/acting.ts +599 -0
  106. package/src/central/agent/actor.ts +193 -0
  107. package/src/central/agent/app-tokens.ts +142 -0
  108. package/src/central/agent/audit.ts +185 -0
  109. package/src/central/agent/decisions.ts +530 -0
  110. package/src/central/agent/delivery.ts +678 -0
  111. package/src/central/agent/directory-routes.ts +185 -0
  112. package/src/central/agent/directory.ts +473 -0
  113. package/src/central/agent/envelope.ts +51 -0
  114. package/src/central/agent/errors.ts +72 -0
  115. package/src/central/agent/escalation.ts +219 -0
  116. package/src/central/agent/grant-requests.ts +221 -0
  117. package/src/central/agent/grant-routes.ts +70 -0
  118. package/src/central/agent/grants.ts +235 -0
  119. package/src/central/agent/launch-engine.ts +1025 -0
  120. package/src/central/agent/launch-routes.ts +252 -0
  121. package/src/central/agent/launch-spec.ts +289 -0
  122. package/src/central/agent/launch-store.ts +264 -0
  123. package/src/central/agent/launch-tokens.ts +206 -0
  124. package/src/central/agent/message-limits.ts +123 -0
  125. package/src/central/agent/message-routes.ts +385 -0
  126. package/src/central/agent/messages.ts +442 -0
  127. package/src/central/agent/names.ts +86 -0
  128. package/src/central/agent/placement.ts +419 -0
  129. package/src/central/agent/policy.ts +378 -0
  130. package/src/central/agent/read-routes.ts +389 -0
  131. package/src/central/agent/routes.ts +278 -0
  132. package/src/central/agent/session-tokens.ts +227 -0
  133. package/src/central/agent/versioned.ts +26 -0
  134. package/src/central/agent/watch.ts +221 -0
  135. package/src/central/agent/webhook-routes.ts +78 -0
  136. package/src/central/agent/webhooks.ts +449 -0
  137. package/src/central/agent-data.ts +177 -0
  138. package/src/central/auth.ts +224 -121
  139. package/src/central/backend/file-state.ts +240 -0
  140. package/src/central/backend/index.ts +113 -0
  141. package/src/central/backend/memory-bus.ts +93 -0
  142. package/src/central/backend/memory-catalog.ts +90 -0
  143. package/src/central/backend/memory-counters.ts +102 -0
  144. package/src/central/backend/memory-events.ts +45 -0
  145. package/src/central/backend/memory-kv.ts +38 -0
  146. package/src/central/backend/memory-state.ts +77 -0
  147. package/src/central/backend/postgres-migrate.ts +100 -0
  148. package/src/central/backend/postgres-state.ts +197 -0
  149. package/src/central/backend/types.ts +244 -0
  150. package/src/central/backend/valkey-bus.ts +425 -0
  151. package/src/central/backend/valkey-catalog.ts +190 -0
  152. package/src/central/backend/valkey-client.ts +36 -0
  153. package/src/central/backend/valkey-counters.ts +38 -0
  154. package/src/central/backend/valkey-events.ts +168 -0
  155. package/src/central/backend/valkey-kv.ts +54 -0
  156. package/src/central/backend/valkey-locks.ts +92 -0
  157. package/src/central/cluster/directory.ts +141 -0
  158. package/src/central/cluster/feed.ts +84 -0
  159. package/src/central/cluster/leader.ts +232 -0
  160. package/src/central/cluster/live.ts +151 -0
  161. package/src/central/cluster/recommend.ts +59 -0
  162. package/src/central/cluster/rpc.ts +150 -0
  163. package/src/central/cluster/view.ts +232 -0
  164. package/src/central/config.ts +197 -3
  165. package/src/central/done.ts +96 -0
  166. package/src/central/events-log.ts +100 -0
  167. package/src/central/http.ts +1067 -171
  168. package/src/central/import.ts +318 -0
  169. package/src/central/main.ts +211 -12
  170. package/src/central/nodes.ts +314 -0
  171. package/src/central/oidc.ts +34 -14
  172. package/src/central/push-target.ts +118 -18
  173. package/src/central/push.ts +256 -82
  174. package/src/central/registry.ts +902 -92
  175. package/src/central/say-stream.ts +128 -0
  176. package/src/central/session-token-push.ts +233 -0
  177. package/src/central/speech/openai.ts +21 -14
  178. package/src/central/speech/openrouter.ts +27 -10
  179. package/src/central/speech/provider.ts +54 -0
  180. package/src/central/speech/service.ts +22 -7
  181. package/src/central/spoke-ws.ts +522 -44
  182. package/src/central/store-shared.ts +518 -0
  183. package/src/central/store.ts +572 -189
  184. package/src/central/token-keys.ts +165 -0
  185. package/src/config.ts +49 -1
  186. package/src/index.ts +165 -12
  187. package/src/protocol.ts +3 -650
  188. package/src/rpc.ts +27 -2
  189. package/src/spoke/client.ts +3 -272
  190. package/src/spoke/conversation.ts +16 -12
  191. package/src/spoke/deliver.ts +75 -0
  192. package/src/spoke/enroll.ts +2 -79
  193. package/src/spoke/env-inject.ts +160 -0
  194. package/src/spoke/hub-rpc.ts +115 -0
  195. package/src/spoke/hub-tool.ts +409 -0
  196. package/src/spoke/launch-source.ts +22 -0
  197. package/src/spoke/launchers.ts +68 -0
  198. package/src/spoke/locations.ts +3 -2
  199. package/src/spoke/node-roots.ts +61 -0
  200. package/src/spoke/prompt-refs.ts +116 -0
  201. package/src/spoke/session-tokens.ts +2 -0
  202. package/src/spoke/spoke.ts +465 -28
  203. package/src/spoke/taskboard.ts +243 -0
  204. package/src/spoke/timeline.ts +184 -6
  205. package/src/spoke/tracker.ts +79 -4
  206. package/src/status.ts +203 -10
  207. package/src/tui-commands.ts +13 -6
  208. package/src/tui-messaging.ts +93 -0
  209. package/src/tui.ts +15 -0
  210. package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
  211. package/web/dist/assets/index-D9zMbxSg.css +1 -0
  212. package/web/dist/assets/index-db1z4duA.js +20 -0
  213. package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
  214. package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
  215. package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
  216. package/web/dist/index.html +9 -2
  217. package/web/dist/sw.js +79 -15
  218. package/web/dist/assets/index-DClBvfLL.css +0 -1
  219. package/web/dist/assets/index-Djo_yF0a.js +0 -12
@@ -0,0 +1,79 @@
1
+ // The hello of a spoke for a harness other than OpenCode (design §1.1, P7-T1): its harness id, version and evidence
2
+ // level, and every feature it serves. Unlike an OpenCode hello it implies nothing: each core op (CORE_OPS) the spoke
3
+ // serves is announced, and the hub sends only announced ops. Refuses what would mislead the hub instead of sending it.
4
+ import { randomBytes } from "node:crypto"
5
+ import {
6
+ CORE_OPS,
7
+ EVIDENCE_LEVELS,
8
+ HARNESS_OPENCODE,
9
+ KNOWN_SPOKE_FEATURES,
10
+ type CoreOp,
11
+ type EvidenceLevel,
12
+ type Hello,
13
+ type ProcessKind,
14
+ type SpokeFeature,
15
+ type SpokeMode,
16
+ } from "../../protocol/src/index.ts"
17
+
18
+ export interface HarnessHelloOptions {
19
+ /** The harness id: a lowercase token (letters, digits, `.`, `_`, `-`), not "opencode". */
20
+ readonly harness: string
21
+ readonly harnessVersion: string
22
+ /** How the spoke observes activity; the hub never treats a session as idle on weaker evidence than it claims. */
23
+ readonly evidence: EvidenceLevel
24
+ /** The spoke's own version. */
25
+ readonly pluginVersion: string
26
+ readonly directory: string
27
+ /** The core ops the spoke serves (a hooks spoke may serve "permission" but not "turn"). */
28
+ readonly ops: readonly CoreOp[]
29
+ /** The optional features it serves (e.g. "deliver", "sessionTokens"); never a core op. */
30
+ readonly features?: readonly SpokeFeature[]
31
+ readonly processID?: string
32
+ readonly kind?: ProcessKind
33
+ readonly mode?: SpokeMode
34
+ readonly pid?: number
35
+ readonly launchID?: string
36
+ }
37
+
38
+ /** The hello's harness id rule (as the hub parses it). */
39
+ const HARNESS = /^[a-z0-9][a-z0-9._-]*$/
40
+ const HARNESS_MAX = 64
41
+ const VERSION_MAX = 100
42
+ const CORE: readonly string[] = CORE_OPS
43
+ const KNOWN: readonly string[] = KNOWN_SPOKE_FEATURES
44
+
45
+ const unique = <T>(values: readonly T[]): T[] => [...new Set(values)]
46
+
47
+ /** The hello fields (without `t` and `v`, which the connection adds) for a non-OpenCode spoke. Throws on bad options. */
48
+ export function buildHarnessHello(options: HarnessHelloOptions): Omit<Hello, "t" | "v"> {
49
+ const { harness, harnessVersion, evidence } = options
50
+ if (harness === HARNESS_OPENCODE) throw new Error("A harness spoke cannot be harness \"opencode\": OpenCode hellos imply every core op; use the OpenCode spoke")
51
+ if (typeof harness !== "string" || harness.length > HARNESS_MAX || !HARNESS.test(harness)) {
52
+ throw new Error(`The harness id must be 1–${HARNESS_MAX} lowercase letters, digits, ".", "_" or "-" (got ${JSON.stringify(harness)})`)
53
+ }
54
+ if (typeof harnessVersion !== "string" || harnessVersion.length > VERSION_MAX) throw new Error(`The harness version must be text of at most ${VERSION_MAX} characters`)
55
+ if (!(EVIDENCE_LEVELS as readonly string[]).includes(evidence)) throw new Error(`The evidence level must be one of ${EVIDENCE_LEVELS.join(", ")} (got ${JSON.stringify(evidence)})`)
56
+ for (const op of options.ops) if (!CORE.includes(op)) throw new Error(`Unknown core op ${JSON.stringify(op)} (core ops: ${CORE_OPS.join(", ")})`)
57
+ const features = unique(options.features ?? [])
58
+ for (const feature of features) {
59
+ if (CORE.includes(feature)) throw new Error(`"${feature}" is a core op: list it in ops, not features`)
60
+ if (!KNOWN.includes(feature)) throw new Error(`Unknown spoke feature ${JSON.stringify(feature)}`)
61
+ }
62
+ if (features.includes("hubPrompt") && !features.includes("deliver")) throw new Error("The hubPrompt feature needs the deliver feature")
63
+ const ops = CORE_OPS.filter((op) => options.ops.includes(op))
64
+ return {
65
+ processID: options.processID ?? `${harness.replace(/[^A-Za-z0-9_-]/g, "_")}_${randomBytes(8).toString("hex")}`,
66
+ kind: options.kind ?? "service",
67
+ directory: options.directory,
68
+ pid: options.pid ?? process.pid,
69
+ mode: options.mode ?? "always",
70
+ // Hubs that predate `harness` require opencodeVersion from every spoke (protocol Hello).
71
+ opencodeVersion: "",
72
+ pluginVersion: options.pluginVersion,
73
+ features: [...ops, ...features],
74
+ harness,
75
+ harnessVersion,
76
+ evidence,
77
+ ...(options.launchID !== undefined ? { launchID: options.launchID } : {}),
78
+ }
79
+ }
@@ -0,0 +1,263 @@
1
+ // HarnessSpoke: the base of a spoke for a harness other than OpenCode (design §1.1, §13.2). It owns the hub
2
+ // connection (SpokeConnection) and does what every such spoke must do the same way:
3
+ //
4
+ // - hello: harness, version, evidence, and exactly the ops it has handlers for (core ops included: a non-OpenCode
5
+ // hello implies none), so the hub sends only those;
6
+ // - op dispatch: an op (or an op option) it did not announce is refused with a clear error, as is an op for a
7
+ // session it does not report (a hub that predates announced core ops sends them anyway);
8
+ // - sessions: the sessions frame on every change and after every welcome; with `livenessMs`, a session that
9
+ // shows no sign of life (upsert, touch, emitted event) for that long is dropped by prune();
10
+ // - emit: only events of the harness subset with valid data (harnessEventProblem), never anything else;
11
+ // - tokens: the session and act tokens the hub sends, in memory, kept apart, for reported sessions only, dropped
12
+ // when the session ends; no token (nor the machine token) is ever logged, serialised or inspected;
13
+ // - deliver: each hub message admitted once per messageID (DeliveryLedger), whatever the harness does with a retry.
14
+ //
15
+ // Session IDs are hub IDs: map native ones with hubSessionID first.
16
+ import {
17
+ CORE_OPS,
18
+ encode,
19
+ featureForOp,
20
+ harnessEventProblem,
21
+ isHubSessionID,
22
+ parseSpokeMessage,
23
+ requestFeatures,
24
+ type HarnessEvent,
25
+ type ResultFor,
26
+ type ServerRequest,
27
+ type SessionInfo,
28
+ type SessionToken,
29
+ type SessionTokenSkipReason,
30
+ type SpokeFeature,
31
+ } from "../../protocol/src/index.ts"
32
+ import { SpokeConnection, type ConnectionOptions, type ConnectionState } from "./connection.ts"
33
+ import { DeliveryLedger, type Admission } from "./delivery.ts"
34
+ import { buildHarnessHello, type HarnessHelloOptions } from "./harness-hello.ts"
35
+ import { SessionTokenStore } from "./session-tokens.ts"
36
+
37
+ const INSPECT = Symbol.for("nodejs.util.inspect.custom")
38
+
39
+ type Op = ServerRequest["op"]
40
+ /** Ops the spoke itself serves for every harness (tokens and deliver's idempotency); the rest need a handler. */
41
+ export type HandledOp = Exclude<Op, "sessionTokens" | "actTokens" | "deliver">
42
+ export type OpHandler<O extends HandledOp> = (req: Extract<ServerRequest, { op: O }>) => Promise<ResultFor[O]> | ResultFor[O]
43
+ export type OpHandlers = { readonly [O in HandledOp]?: OpHandler<O> }
44
+ export type DeliverRequest = Extract<ServerRequest, { op: "deliver" }>
45
+ /**
46
+ * Features that change how an op is served rather than adding one; each needs the op it modifies, except
47
+ * "turnEvents" (the spoke reports every turn's end as the harness confirms it), which is about events, not an op.
48
+ */
49
+ export type ModifierFeature = "permissionMessage" | "launch" | "hubPrompt" | "turnEvents"
50
+
51
+ export interface HarnessSpokeOptions extends Omit<HarnessHelloOptions, "ops" | "features"> {
52
+ readonly server: string
53
+ /** The machine token. Sent only as the WebSocket's bearer; never logged or serialised. */
54
+ readonly token: string
55
+ readonly handlers?: OpHandlers
56
+ /** Admits a hub message into a session (feature "deliver"); run once per messageID (DeliveryLedger). */
57
+ readonly deliver?: (req: DeliverRequest) => Promise<Admission>
58
+ readonly features?: readonly ModifierFeature[]
59
+ /** Drop a session after this long without a sign of life (prune); 0 or absent: never. */
60
+ readonly livenessMs?: number
61
+ readonly now?: () => number
62
+ readonly socket?: ConnectionOptions["socket"]
63
+ readonly backoff?: ConnectionOptions["backoff"]
64
+ readonly requestTimeoutMs?: number
65
+ readonly onState?: ConnectionOptions["onState"]
66
+ }
67
+
68
+ const MODIFIER_NEEDS: Record<ModifierFeature, string | null> = { permissionMessage: "permission", launch: "create", hubPrompt: "deliver", turnEvents: null }
69
+ const ALWAYS: readonly SpokeFeature[] = ["sessionTokens", "actTokens"]
70
+ const CORE: readonly string[] = CORE_OPS
71
+
72
+ export class HarnessSpoke {
73
+ /** Every feature the hello announces (core ops first). */
74
+ readonly features: readonly SpokeFeature[]
75
+ readonly harness: string
76
+
77
+ readonly #handlers: OpHandlers
78
+ readonly #deliver?: HarnessSpokeOptions["deliver"]
79
+ readonly #announced: ReadonlySet<string>
80
+ readonly #connection: SpokeConnection
81
+ readonly #sessions = new Map<string, { info: SessionInfo; seen: number }>()
82
+ readonly #livenessMs: number
83
+ readonly #now: () => number
84
+ readonly #sessionTokens: SessionTokenStore
85
+ readonly #actTokens: SessionTokenStore
86
+ readonly #ledger = new DeliveryLedger()
87
+
88
+ constructor(options: HarnessSpokeOptions) {
89
+ this.harness = options.harness
90
+ this.#handlers = options.handlers ?? {}
91
+ this.#deliver = options.deliver
92
+ this.#livenessMs = options.livenessMs ?? 0
93
+ this.#now = options.now ?? Date.now
94
+ this.#sessionTokens = new SessionTokenStore(this.#now)
95
+ this.#actTokens = new SessionTokenStore(this.#now)
96
+ const handled = (Object.keys(this.#handlers) as HandledOp[]).filter((op) => typeof this.#handlers[op] === "function")
97
+ if (handled.includes("modelPrefs") !== handled.includes("setModelPrefs")) throw new Error("The modelPrefs feature needs both a modelPrefs and a setModelPrefs handler")
98
+ const ops = handled.filter((op) => CORE.includes(op)) as (typeof CORE_OPS)[number][]
99
+ const served = new Set<string>(handled)
100
+ if (this.#deliver) served.add("deliver")
101
+ const modifiers = [...new Set(options.features ?? [])]
102
+ for (const feature of modifiers) {
103
+ const needs = MODIFIER_NEEDS[feature]
104
+ if (needs === undefined || !Object.hasOwn(MODIFIER_NEEDS, feature)) throw new Error(`Unknown modifier feature ${JSON.stringify(feature)}`)
105
+ if (needs !== null && !served.has(needs)) throw new Error(`The ${feature} feature needs a ${needs} handler`)
106
+ }
107
+ const optional = [...new Set(handled.filter((op) => !CORE.includes(op)).map((op) => featureForOp(op)))]
108
+ const hello = buildHarnessHello({ ...options, ops, features: [...optional, ...(this.#deliver ? ["deliver" as const] : []), ...ALWAYS, ...modifiers] })
109
+ this.features = hello.features as SpokeFeature[]
110
+ this.#announced = new Set(this.features)
111
+ this.#connection = new SpokeConnection({
112
+ server: options.server,
113
+ token: options.token,
114
+ hello: () => hello,
115
+ handle: (req) => this.dispatch(req),
116
+ onWelcome: () => this.#sendSessions(),
117
+ ...(options.onState ? { onState: options.onState } : {}),
118
+ ...(options.backoff ? { backoff: options.backoff } : {}),
119
+ ...(options.requestTimeoutMs !== undefined ? { requestTimeoutMs: options.requestTimeoutMs } : {}),
120
+ ...(options.socket ? { socket: options.socket } : {}),
121
+ })
122
+ }
123
+
124
+ get state(): ConnectionState {
125
+ return this.#connection.state
126
+ }
127
+
128
+ start(): void {
129
+ this.#connection.start()
130
+ }
131
+
132
+ stop(): void {
133
+ this.#connection.stop()
134
+ }
135
+
136
+ retryNow(): void {
137
+ this.#connection.retryNow()
138
+ }
139
+
140
+ /** Whether the connected hub announced `feature` in its welcome. */
141
+ hubSupports(feature: string): boolean {
142
+ return this.#connection.hubSupports(feature)
143
+ }
144
+
145
+ /** Runs a hub request: refuses unannounced ops and unknown sessions, then calls the op's handler. */
146
+ async dispatch(req: ServerRequest): Promise<unknown> {
147
+ for (const feature of requestFeatures(req)) {
148
+ if (!this.#announced.has(feature)) throw new Error(`The ${this.harness} spoke does not serve "${feature}" (not announced in its hello)`)
149
+ }
150
+ if ("sessionID" in req && !this.#sessions.has(req.sessionID)) throw new Error(`Unknown session ${req.sessionID}: this ${this.harness} spoke does not report it`)
151
+ switch (req.op) {
152
+ case "sessionTokens":
153
+ if (req.hubURL !== undefined) this.#sessionTokens.hubURL = req.hubURL
154
+ return this.#storeTokens(this.#sessionTokens, req.tokens)
155
+ case "actTokens":
156
+ return this.#storeTokens(this.#actTokens, req.tokens)
157
+ case "deliver": {
158
+ const deliver = this.#deliver!
159
+ return this.#ledger.run(req.messageID, req.wake, () => deliver(req))
160
+ }
161
+ }
162
+ const handler = this.#handlers[req.op as HandledOp] as ((r: ServerRequest) => unknown) | undefined
163
+ if (!handler) throw new Error(`The ${this.harness} spoke has no handler for "${req.op}"`)
164
+ return await handler(req)
165
+ }
166
+
167
+ /** Reports a session (new or changed) and counts as a sign of life. Its IDs must be hub session IDs. */
168
+ upsertSession(info: SessionInfo): void {
169
+ if (!isHubSessionID(info?.id)) throw new Error(`Session id must be a hub session ID (ses_ + letters and digits; map native IDs with hubSessionID), got ${JSON.stringify(info?.id)}`)
170
+ if (info.parentID !== undefined && !isHubSessionID(info.parentID)) throw new Error(`Session parentID must be a hub session ID, got ${JSON.stringify(info.parentID)}`)
171
+ if (!parseSpokeMessage(encode({ t: "sessions", sessions: [info] }))) throw new Error(`Session ${info.id} is not a valid session for the hub (see the protocol's SessionInfo)`)
172
+ this.#sessions.set(info.id, { info, seen: this.#now() })
173
+ this.#sendSessions()
174
+ }
175
+
176
+ /** A sign of life from a session (keeps it from being pruned). */
177
+ touch(sessionID: string): void {
178
+ const entry = this.#sessions.get(sessionID)
179
+ if (entry) entry.seen = this.#now()
180
+ }
181
+
182
+ /** The session ended: it is no longer reported and its tokens are dropped. */
183
+ endSession(sessionID: string): void {
184
+ this.#dropTokens(sessionID)
185
+ if (this.#sessions.delete(sessionID)) this.#sendSessions()
186
+ }
187
+
188
+ /**
189
+ * The session's current session token (for its shells: `hub` there acts as the session) and the URL they reach
190
+ * the hub at. Hand it to the harness's process environment only, never to a log.
191
+ */
192
+ sessionToken(sessionID: string): { token: string; url?: string } | undefined {
193
+ const entry = this.#sessionTokens.get(sessionID)
194
+ if (!entry) return undefined
195
+ const url = this.#sessionTokens.hubURL ?? this.#connection.publicURL
196
+ return { token: entry.token, ...(url !== undefined ? { url } : {}) }
197
+ }
198
+
199
+ /** The session's act token (act verbs only; never in any environment) and the hub's URL. */
200
+ actToken(sessionID: string): { token: string; url: string } | undefined {
201
+ const entry = this.#actTokens.get(sessionID)
202
+ const url = this.#sessionTokens.hubURL ?? this.#connection.publicURL
203
+ return entry && url !== undefined ? { token: entry.token, url } : undefined
204
+ }
205
+
206
+ toJSON(): { harness: string; state: ConnectionState; sessions: number; features: readonly SpokeFeature[] } {
207
+ return { harness: this.harness, state: this.state, sessions: this.#sessions.size, features: this.features }
208
+ }
209
+
210
+ toString(): string {
211
+ return `HarnessSpoke(${this.harness}, ${this.state}, ${this.#sessions.size} sessions)`
212
+ }
213
+
214
+ [INSPECT](): string {
215
+ return this.toString()
216
+ }
217
+
218
+ sessions(): SessionInfo[] {
219
+ return [...this.#sessions.values()].map((e) => e.info)
220
+ }
221
+
222
+ /** Drops sessions without a sign of life for livenessMs (none when it is 0); returns their IDs. */
223
+ prune(): string[] {
224
+ if (this.#livenessMs <= 0) return []
225
+ const cutoff = this.#now() - this.#livenessMs
226
+ const dropped = [...this.#sessions].filter(([, e]) => e.seen < cutoff).map(([id]) => id)
227
+ for (const id of dropped) {
228
+ this.#sessions.delete(id)
229
+ this.#dropTokens(id)
230
+ }
231
+ if (dropped.length) this.#sendSessions()
232
+ return dropped
233
+ }
234
+
235
+ /** Sends a harness event (T1 subset, validated); throws on an invalid one. False when it could not be sent. */
236
+ emit(event: HarnessEvent): boolean {
237
+ const problem = harnessEventProblem(event)
238
+ if (problem) throw new Error(`Invalid harness event${typeof (event as { type?: unknown })?.type === "string" ? ` ${(event as { type: string }).type}` : ""}: ${problem}`)
239
+ const sessionID = (event.data as { sessionID?: unknown }).sessionID
240
+ if (typeof sessionID === "string") this.touch(sessionID)
241
+ return this.#connection.send({ t: "event", event })
242
+ }
243
+
244
+ #storeTokens(store: SessionTokenStore, tokens: readonly SessionToken[]): ResultFor["sessionTokens"] {
245
+ const applied: string[] = []
246
+ const skipped: { sessionID: string; reason: SessionTokenSkipReason }[] = []
247
+ for (const { sessionID, token, exp } of tokens) {
248
+ if (!this.#sessions.has(sessionID)) skipped.push({ sessionID, reason: "unknown" })
249
+ else if (store.set(sessionID, token, exp)) applied.push(sessionID)
250
+ else skipped.push({ sessionID, reason: "expired" })
251
+ }
252
+ return { applied, skipped }
253
+ }
254
+
255
+ #dropTokens(sessionID: string): void {
256
+ this.#sessionTokens.delete(sessionID)
257
+ this.#actTokens.delete(sessionID)
258
+ }
259
+
260
+ #sendSessions(): void {
261
+ this.#connection.send({ t: "sessions", sessions: this.sessions() })
262
+ }
263
+ }
@@ -0,0 +1,12 @@
1
+ // @huynle/hub-spoke-sdk: the harness-neutral parts of a hub spoke (design §13.3). Imports only the protocol and SDK
2
+ // packages, never the hub (src/central) or the OpenCode spoke (src/spoke).
3
+ export * from "./activity.ts"
4
+ export * from "./connection.ts"
5
+ export * from "./delivery.ts"
6
+ export * from "./enroll.ts"
7
+ export * from "./prompt-refs.ts"
8
+ export * from "./prompt-source.ts"
9
+ export * from "./session-tokens.ts"
10
+ export * from "./session-ids.ts"
11
+ export * from "./harness-hello.ts"
12
+ export * from "./harness-spoke.ts"
@@ -0,0 +1,154 @@
1
+ // Prompt references (design §2.4): hub addresses typed into a prompt get context, never routing. Finding the
2
+ // addresses, resolving them against what the hub reports, and rendering the context note are harness-neutral;
3
+ // how the note reaches the model (OpenCode: an inline text file on the prompt) is the spoke's.
4
+ import { parseAddress, sanitizeLabel, type AgentMessage, type DirectoryEntry, type Status } from "../../sdk/src/index.ts"
5
+
6
+ export interface Mention {
7
+ readonly start: number
8
+ readonly end: number
9
+ readonly text: string
10
+ }
11
+
12
+ export interface PromptLike {
13
+ readonly text: string
14
+ readonly files?: readonly { readonly uri?: string; readonly name?: string; readonly mention?: Mention }[]
15
+ readonly agents?: readonly { readonly name: string; readonly mention?: Mention }[]
16
+ readonly skills?: readonly { readonly name?: string; readonly mention?: Mention }[]
17
+ }
18
+
19
+ /**
20
+ * `@name`, `@name.project`, `@@role`, `@@role.project`: names and roles are 2–40 lowercase letters, digits and
21
+ * dashes (packages/sdk/src/address.ts); not part of a word, email or path, and not followed by one.
22
+ */
23
+ const ADDRESS = /(?<![\w@./\\-])(@@?)([a-z0-9-]{2,40})(?:\.([A-Za-z0-9_-]+))?(?![\w@/\\-]|\.[\w/\\-])/g
24
+
25
+ /** The user's own address (not a session). */
26
+ const SELF = "me"
27
+
28
+ /** The hub addresses a prompt mentions, once each, in order; those the harness already gave a meaning are skipped. */
29
+ export function findReferences(prompt: PromptLike): string[] {
30
+ const text = typeof prompt?.text === "string" ? prompt.text : ""
31
+ if (!text.includes("@")) return []
32
+ const mentions = [...(prompt.files ?? []), ...(prompt.agents ?? []), ...(prompt.skills ?? [])].map((m) => m?.mention).filter((m): m is Mention => !!m)
33
+ const agentNames = new Set((prompt.agents ?? []).map((a) => a?.name).filter(Boolean))
34
+ const found: string[] = []
35
+ for (const match of text.matchAll(ADDRESS)) {
36
+ const [token, ats, name] = match as unknown as [string, string, string]
37
+ const start = match.index ?? 0
38
+ const end = start + token.length
39
+ const role = ats === "@@"
40
+ if (!role && name === SELF) continue
41
+ if (mentions.some((m) => m.start < end && m.end > start)) continue
42
+ if (!role && (agentNames.has(name) || mentions.some((m) => m.text === token || m.text === `@${name}`))) continue
43
+ if (!found.includes(token)) found.push(token)
44
+ }
45
+ return found
46
+ }
47
+
48
+ /** What the hub told this session (who, status, inbox waiting), fetched once per lookup. */
49
+ export interface ReferenceSnapshot {
50
+ readonly who: readonly DirectoryEntry[]
51
+ readonly status?: Status
52
+ /** The session's own open asks (inbox waiting). */
53
+ readonly waiting?: readonly AgentMessage[]
54
+ }
55
+
56
+ export interface ReferencedSession {
57
+ readonly sessionID: string
58
+ readonly name?: string
59
+ readonly harness?: string
60
+ readonly machine?: string
61
+ readonly project?: string
62
+ readonly state: string
63
+ /** Open asks from the prompting session to this one. */
64
+ readonly openAsks: number
65
+ /** The prompting session itself. */
66
+ readonly self: boolean
67
+ }
68
+
69
+ export interface Reference {
70
+ readonly address: string
71
+ readonly sessions: readonly ReferencedSession[]
72
+ }
73
+
74
+ /** The same scope rule as the hub's directory: a claim's project, else the session's project. */
75
+ const inScope = (claim: string | undefined, session: string | undefined, wanted: string | undefined) =>
76
+ wanted === undefined || claim === wanted || (claim === undefined && session === wanted)
77
+
78
+ /** The sessions each address names in the snapshot (the caller's own world); unknown addresses are dropped. */
79
+ export function resolveReferences(addresses: readonly string[], snapshot: ReferenceSnapshot, self: string): Reference[] {
80
+ const live = new Map<string, { state: string; harness?: string }>()
81
+ const machines = new Map<string, string>()
82
+ for (const machine of snapshot.status?.machines ?? []) {
83
+ if (machine.name) machines.set(machine.id, machine.name)
84
+ for (const project of machine.projects) for (const s of project.sessions) live.set(s.id, { state: s.state, ...(s.harness ? { harness: s.harness } : {}) })
85
+ }
86
+ const asks = new Map<string, number>()
87
+ for (const m of snapshot.waiting ?? []) {
88
+ if (m.state !== "open" || m.recipient?.kind !== "session") continue
89
+ asks.set(m.recipient.sessionID, (asks.get(m.recipient.sessionID) ?? 0) + 1)
90
+ }
91
+ const view = (e: DirectoryEntry): ReferencedSession => {
92
+ const now = live.get(e.sessionID)
93
+ const machine = e.machineID ? machines.get(e.machineID) : undefined
94
+ return {
95
+ sessionID: e.sessionID,
96
+ ...(e.name ? { name: e.name } : {}),
97
+ ...(now?.harness ? { harness: now.harness } : {}),
98
+ ...(machine ? { machine } : {}),
99
+ ...(e.project ? { project: e.project } : {}),
100
+ state: now?.state ?? (e.online ? (e.state ?? "online") : "offline"),
101
+ openAsks: asks.get(e.sessionID) ?? 0,
102
+ self: e.sessionID === self,
103
+ }
104
+ }
105
+ const out: Reference[] = []
106
+ for (const address of addresses) {
107
+ const parsed = parseAddress(address)
108
+ if (!parsed || parsed.kind === "id") continue
109
+ const matches = snapshot.who.filter((e) =>
110
+ parsed.kind === "name"
111
+ ? (e.name === parsed.name || (e.aliases ?? []).includes(parsed.name)) && (parsed.project === undefined || e.project === parsed.project)
112
+ : e.roles.some((r) => r.role === parsed.role && inScope(r.project, e.project, parsed.project)),
113
+ )
114
+ if (matches.length) out.push({ address, sessions: matches.map(view) })
115
+ }
116
+ return out
117
+ }
118
+
119
+ const clean = (value: string | undefined) => (value === undefined ? "" : sanitizeLabel(value))
120
+
121
+ function details(s: ReferencedSession): string[] {
122
+ return [clean(s.harness), clean(s.machine), s.project ? `project ${clean(s.project)}` : ""].filter(Boolean)
123
+ }
124
+
125
+ function tail(s: ReferencedSession): string {
126
+ const asks = s.openAsks ? `, ${s.openAsks} open ask${s.openAsks === 1 ? "" : "s"} from you` : ""
127
+ return `${clean(s.state) || "unknown"}${asks}`
128
+ }
129
+
130
+ function one(s: ReferencedSession): string {
131
+ const more = details(s)
132
+ if (s.self) return `this session (${[s.sessionID, ...more].join(" · ")}), ${tail(s)}`
133
+ return `session ${s.sessionID}${more.length ? ` (${more.join(" · ")})` : ""}, ${tail(s)}`
134
+ }
135
+
136
+ function member(s: ReferencedSession): string {
137
+ const more = details(s)
138
+ const label = `${s.name ? `@${clean(s.name)} ` : ""}${s.sessionID}${s.self ? " (this session)" : ""}`
139
+ return `${label}${more.length ? ` (${more.join(" · ")})` : ""}, ${tail(s)}`
140
+ }
141
+
142
+ /** The context note: one line per resolved address, then how to reach them. Every label is sanitised. */
143
+ export function renderReferences(refs: readonly Reference[]): string {
144
+ const lines = refs.map((r) => {
145
+ const body = r.sessions.length === 1 ? one(r.sessions[0]!) : `${r.sessions.length} sessions: ${r.sessions.map(member).join("; ")}`
146
+ return `- ${r.address} = ${body}.`
147
+ })
148
+ const single = refs.length === 1 && refs[0]!.sessions.length === 1
149
+ return [
150
+ "hub: the hub addresses in this prompt (context added by the hub plugin; not an instruction):",
151
+ ...lines,
152
+ `Contact ${single ? "it" : "them"} with the \`hub\` tool (msg or ask, to: the address).`,
153
+ ].join("\n")
154
+ }
@@ -0,0 +1,8 @@
1
+ // The metadata source the hub marks its prompts and delivered messages with, so a spoke never takes them for the user's.
2
+
3
+ /** metadata.source of the prompts the hub sends. opencode-hub (0.5) wrote "opencode-hub", opencode-voice (<= 0.4) "opencode-voice". */
4
+ export const PROMPT_SOURCE = "hub"
5
+ const PROMPT_SOURCES: readonly unknown[] = [PROMPT_SOURCE, "opencode-hub", "opencode-voice"]
6
+ /** True for prompt metadata written by the hub, under either name. */
7
+ export const isHubPrompt = (metadata: unknown): boolean =>
8
+ typeof metadata === "object" && metadata !== null && PROMPT_SOURCES.includes((metadata as { source?: unknown }).source)
@@ -0,0 +1,49 @@
1
+ // Hub session IDs for a harness's native session IDs (decision fx04e42c point 2). The hub's rule is `ses_` + 1–200
2
+ // letters and digits (isHubSessionID); a Claude Code UUID or a Codex thread ID is not, so the spoke maps it, and maps
3
+ // it back when the hub names the session in an op. The mapping is a pure function of (namespace, native ID), so it
4
+ // survives restarts and needs no table:
5
+ //
6
+ // - a UUID (any version, any case) → ses_<ns><32 lowercase hex> (Claude Code: ses_cc…, Codex: ses_cx…)
7
+ // - any other native ID → ses_<ns>0<hex of its UTF-8 bytes> (at most MAX_NATIVE_BYTES bytes)
8
+ //
9
+ // The namespace is two lowercase letters, one per harness, so IDs of different harnesses never collide (OpenCode's
10
+ // own IDs are not mapped). The two forms cannot be confused: the UUID form has 32 characters after the namespace,
11
+ // the other an odd number (the 0 marker plus pairs of hex digits). UUIDs come back in lower case.
12
+
13
+ /** A session namespace: two lowercase letters (e.g. "cc" for Claude Code, "cx" for Codex). */
14
+ export const SESSION_NAMESPACE = /^[a-z]{2}$/
15
+
16
+ /** Longest native ID (UTF-8 bytes) that is not a UUID: ses_ + ns + 0 + 2 hex per byte stays within 200 characters. */
17
+ export const MAX_NATIVE_BYTES = 98
18
+
19
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
20
+ const UUID_HEX = /^[0-9a-f]{32}$/
21
+ const OTHER_HEX = /^0((?:[0-9a-f]{2})+)$/
22
+
23
+ function checkNamespace(ns: string): void {
24
+ if (typeof ns !== "string" || !SESSION_NAMESPACE.test(ns)) throw new Error(`Session namespace must be two lowercase letters (got ${JSON.stringify(ns)})`)
25
+ }
26
+
27
+ /** The hub session ID for a native session ID in namespace `ns`. Throws on a bad namespace or an unmappable ID. */
28
+ export function hubSessionID(ns: string, nativeID: string): string {
29
+ checkNamespace(ns)
30
+ if (typeof nativeID !== "string" || nativeID === "") throw new Error("Native session ID is empty")
31
+ if (UUID.test(nativeID)) return `ses_${ns}${nativeID.replace(/-/g, "").toLowerCase()}`
32
+ const bytes = Buffer.from(nativeID, "utf8")
33
+ if (bytes.length > MAX_NATIVE_BYTES) throw new Error(`Native session ID is too long (${bytes.length} bytes, at most ${MAX_NATIVE_BYTES})`)
34
+ return `ses_${ns}0${bytes.toString("hex")}`
35
+ }
36
+
37
+ /** The native session ID a hub session ID in namespace `ns` stands for; undefined when it is not one of them. */
38
+ export function nativeSessionID(ns: string, hubID: string): string | undefined {
39
+ if (typeof ns !== "string" || !SESSION_NAMESPACE.test(ns) || typeof hubID !== "string") return undefined
40
+ const prefix = `ses_${ns}`
41
+ if (!hubID.startsWith(prefix)) return undefined
42
+ const rest = hubID.slice(prefix.length)
43
+ if (UUID_HEX.test(rest)) return `${rest.slice(0, 8)}-${rest.slice(8, 12)}-${rest.slice(12, 16)}-${rest.slice(16, 20)}-${rest.slice(20)}`
44
+ const other = OTHER_HEX.exec(rest)
45
+ if (!other || other[1]!.length / 2 > MAX_NATIVE_BYTES) return undefined
46
+ const native = Buffer.from(other[1]!, "hex").toString("utf8")
47
+ // Only IDs hubSessionID makes: valid UTF-8 that round-trips, and not a UUID (those use the short form).
48
+ return native !== "" && !UUID.test(native) && hubSessionID(ns, native) === hubID ? native : undefined
49
+ }
@@ -0,0 +1,78 @@
1
+ // The session tokens the hub sent this spoke (sessionTokens op), one per session, kept in memory only. The spoke
2
+ // hands a session's current token to its shells (P1-T6, plugin hooks), so `hub` there acts as that session.
3
+ // A newer token replaces the older one; expired ones are dropped when read. Tokens never appear in JSON, inspect
4
+ // or string output, so logging the store (or an object holding it) cannot leak them.
5
+
6
+ export interface StoredSessionToken {
7
+ readonly token: string
8
+ /** Expiry, ms since the epoch. */
9
+ readonly exp: number
10
+ }
11
+
12
+ const INSPECT = Symbol.for("nodejs.util.inspect.custom")
13
+
14
+ export class SessionTokenStore {
15
+ /** The URL the sessions' shells reach the hub at, as the hub last sent it. */
16
+ hubURL?: string
17
+ readonly #tokens = new Map<string, StoredSessionToken>()
18
+ readonly #now: () => number
19
+
20
+ constructor(now: () => number = Date.now) {
21
+ this.#now = now
22
+ }
23
+
24
+ /** Stores the session's token, replacing its older one; false (nothing stored) when `exp` has passed. */
25
+ set(sessionID: string, token: string, exp: number): boolean {
26
+ if (exp <= this.#now()) return false
27
+ this.#tokens.set(sessionID, { token, exp })
28
+ return true
29
+ }
30
+
31
+ /** The session's current token, or undefined when it has none or it expired (then it is dropped). */
32
+ get(sessionID: string): StoredSessionToken | undefined {
33
+ const entry = this.#tokens.get(sessionID)
34
+ if (!entry) return undefined
35
+ if (entry.exp > this.#now()) return entry
36
+ this.#tokens.delete(sessionID)
37
+ return undefined
38
+ }
39
+
40
+ /** Sessions holding an unexpired token. */
41
+ sessions(): string[] {
42
+ this.prune()
43
+ return [...this.#tokens.keys()]
44
+ }
45
+
46
+ get size(): number {
47
+ this.prune()
48
+ return this.#tokens.size
49
+ }
50
+
51
+ /** Drops expired tokens. */
52
+ prune(): void {
53
+ const now = this.#now()
54
+ for (const [sessionID, entry] of this.#tokens) if (entry.exp <= now) this.#tokens.delete(sessionID)
55
+ }
56
+
57
+ delete(sessionID: string): void {
58
+ this.#tokens.delete(sessionID)
59
+ }
60
+
61
+ /** Forgets every token and the hub URL. */
62
+ clear(): void {
63
+ this.#tokens.clear()
64
+ this.hubURL = undefined
65
+ }
66
+
67
+ toJSON(): { sessions: number; hubURL?: string } {
68
+ return { sessions: this.size, ...(this.hubURL !== undefined ? { hubURL: this.hubURL } : {}) }
69
+ }
70
+
71
+ toString(): string {
72
+ return `SessionTokenStore(${this.size} sessions)`
73
+ }
74
+
75
+ [INSPECT](): string {
76
+ return this.toString()
77
+ }
78
+ }