@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.
- package/CHANGELOG.md +800 -0
- package/Dockerfile +30 -0
- package/README.md +870 -66
- package/package.json +45 -2
- package/packages/claude-code/package.json +12 -0
- package/packages/claude-code/src/bridge.ts +79 -0
- package/packages/claude-code/src/daemon.ts +339 -0
- package/packages/claude-code/src/hook-input.ts +44 -0
- package/packages/claude-code/src/ids.ts +27 -0
- package/packages/claude-code/src/index.ts +18 -0
- package/packages/claude-code/src/launcher.ts +265 -0
- package/packages/claude-code/src/main.ts +127 -0
- package/packages/claude-code/src/mcp.ts +109 -0
- package/packages/claude-code/src/paths.ts +34 -0
- package/packages/claude-code/src/plugin.ts +99 -0
- package/packages/claude-code/src/references.ts +51 -0
- package/packages/claude-code/src/sessions.ts +600 -0
- package/packages/claude-code/src/tools.ts +66 -0
- package/packages/claude-code/src/wire.ts +71 -0
- package/packages/claude-code/src/wrapper.ts +155 -0
- package/packages/cli/dist/hub-cli.js +10162 -0
- package/packages/codex/package.json +12 -0
- package/packages/codex/src/app-server.ts +44 -0
- package/packages/codex/src/ids.ts +28 -0
- package/packages/codex/src/index.ts +11 -0
- package/packages/codex/src/jsonrpc.ts +168 -0
- package/packages/codex/src/main.ts +122 -0
- package/packages/codex/src/paths.ts +65 -0
- package/packages/codex/src/sessions.ts +578 -0
- package/packages/codex/src/spoke.ts +249 -0
- package/packages/codex/src/stdio.ts +101 -0
- package/packages/codex/src/websocket.ts +206 -0
- package/packages/codex/src/wrapper.ts +100 -0
- package/packages/node/package.json +12 -0
- package/packages/node/src/config.ts +184 -0
- package/packages/node/src/connection.ts +283 -0
- package/packages/node/src/daemon.ts +281 -0
- package/packages/node/src/git.ts +107 -0
- package/packages/node/src/harnesses.ts +60 -0
- package/packages/node/src/host.ts +319 -0
- package/packages/node/src/index.ts +17 -0
- package/packages/node/src/main.ts +11 -0
- package/packages/node/src/openshell.ts +73 -0
- package/packages/node/src/run.ts +106 -0
- package/packages/preset/package.json +12 -0
- package/packages/preset/presets/aider.json +8 -0
- package/packages/preset/presets/goose.json +8 -0
- package/packages/preset/src/index.ts +2 -0
- package/packages/preset/src/main.ts +195 -0
- package/packages/preset/src/presets.ts +87 -0
- package/packages/preset/src/spoke.ts +251 -0
- package/packages/preset/src/tmux.ts +160 -0
- package/packages/protocol/package.json +12 -0
- package/packages/protocol/src/harness-events.ts +207 -0
- package/packages/protocol/src/index.ts +1215 -0
- package/packages/protocol/src/launch.ts +172 -0
- package/packages/protocol/src/node.ts +409 -0
- package/packages/protocol/src/preset.ts +144 -0
- package/packages/protocol/src/webhook.ts +62 -0
- package/packages/protocol/src/wire.ts +54 -0
- package/packages/runtime-openshell/image/Dockerfile +36 -0
- package/packages/runtime-openshell/image/README.md +78 -0
- package/packages/runtime-openshell/image/build.ts +203 -0
- package/packages/runtime-openshell/image/entry.sh +163 -0
- package/packages/runtime-openshell/image/image.ts +119 -0
- package/packages/runtime-openshell/image/start.sh +24 -0
- package/packages/runtime-openshell/package.json +12 -0
- package/packages/runtime-openshell/src/cli.ts +584 -0
- package/packages/runtime-openshell/src/index.ts +6 -0
- package/packages/runtime-openshell/src/policy.ts +155 -0
- package/packages/runtime-openshell/src/profiles.ts +181 -0
- package/packages/runtime-openshell/src/runtime.ts +475 -0
- package/packages/runtime-sdk/package.json +12 -0
- package/packages/runtime-sdk/src/index.ts +87 -0
- package/packages/sdk/package.json +12 -0
- package/packages/sdk/src/address.ts +25 -0
- package/packages/sdk/src/client.ts +502 -0
- package/packages/sdk/src/envelope.ts +108 -0
- package/packages/sdk/src/errors.ts +106 -0
- package/packages/sdk/src/index.ts +10 -0
- package/packages/sdk/src/sse.ts +67 -0
- package/packages/sdk/src/types.ts +458 -0
- package/packages/sdk/src/webhook.ts +102 -0
- package/packages/spoke-sdk/package.json +12 -0
- package/packages/spoke-sdk/src/activity.ts +3 -0
- package/packages/spoke-sdk/src/connection.ts +295 -0
- package/packages/spoke-sdk/src/delivery.ts +119 -0
- package/packages/spoke-sdk/src/enroll.ts +79 -0
- package/packages/spoke-sdk/src/harness-hello.ts +79 -0
- package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
- package/packages/spoke-sdk/src/index.ts +12 -0
- package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
- package/packages/spoke-sdk/src/prompt-source.ts +8 -0
- package/packages/spoke-sdk/src/session-ids.ts +49 -0
- package/packages/spoke-sdk/src/session-tokens.ts +78 -0
- package/scripts/hub-claude-code.service +34 -0
- package/scripts/hub-codex-spoke.service +34 -0
- package/scripts/hub-node.service +35 -0
- package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
- package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
- package/scripts/io.github.huynle.hub-node.plist +52 -0
- package/src/central/actions.ts +233 -0
- package/src/central/agent/acting-routes.ts +133 -0
- package/src/central/agent/acting-switch.ts +93 -0
- package/src/central/agent/acting.ts +599 -0
- package/src/central/agent/actor.ts +193 -0
- package/src/central/agent/app-tokens.ts +142 -0
- package/src/central/agent/audit.ts +185 -0
- package/src/central/agent/decisions.ts +530 -0
- package/src/central/agent/delivery.ts +678 -0
- package/src/central/agent/directory-routes.ts +185 -0
- package/src/central/agent/directory.ts +473 -0
- package/src/central/agent/envelope.ts +51 -0
- package/src/central/agent/errors.ts +72 -0
- package/src/central/agent/escalation.ts +219 -0
- package/src/central/agent/grant-requests.ts +221 -0
- package/src/central/agent/grant-routes.ts +70 -0
- package/src/central/agent/grants.ts +235 -0
- package/src/central/agent/launch-engine.ts +1025 -0
- package/src/central/agent/launch-routes.ts +252 -0
- package/src/central/agent/launch-spec.ts +289 -0
- package/src/central/agent/launch-store.ts +264 -0
- package/src/central/agent/launch-tokens.ts +206 -0
- package/src/central/agent/message-limits.ts +123 -0
- package/src/central/agent/message-routes.ts +385 -0
- package/src/central/agent/messages.ts +442 -0
- package/src/central/agent/names.ts +86 -0
- package/src/central/agent/placement.ts +419 -0
- package/src/central/agent/policy.ts +378 -0
- package/src/central/agent/read-routes.ts +389 -0
- package/src/central/agent/routes.ts +278 -0
- package/src/central/agent/session-tokens.ts +227 -0
- package/src/central/agent/versioned.ts +26 -0
- package/src/central/agent/watch.ts +221 -0
- package/src/central/agent/webhook-routes.ts +78 -0
- package/src/central/agent/webhooks.ts +449 -0
- package/src/central/agent-data.ts +177 -0
- package/src/central/auth.ts +224 -121
- package/src/central/backend/file-state.ts +240 -0
- package/src/central/backend/index.ts +113 -0
- package/src/central/backend/memory-bus.ts +93 -0
- package/src/central/backend/memory-catalog.ts +90 -0
- package/src/central/backend/memory-counters.ts +102 -0
- package/src/central/backend/memory-events.ts +45 -0
- package/src/central/backend/memory-kv.ts +38 -0
- package/src/central/backend/memory-state.ts +77 -0
- package/src/central/backend/postgres-migrate.ts +100 -0
- package/src/central/backend/postgres-state.ts +197 -0
- package/src/central/backend/types.ts +244 -0
- package/src/central/backend/valkey-bus.ts +425 -0
- package/src/central/backend/valkey-catalog.ts +190 -0
- package/src/central/backend/valkey-client.ts +36 -0
- package/src/central/backend/valkey-counters.ts +38 -0
- package/src/central/backend/valkey-events.ts +168 -0
- package/src/central/backend/valkey-kv.ts +54 -0
- package/src/central/backend/valkey-locks.ts +92 -0
- package/src/central/cluster/directory.ts +141 -0
- package/src/central/cluster/feed.ts +84 -0
- package/src/central/cluster/leader.ts +232 -0
- package/src/central/cluster/live.ts +151 -0
- package/src/central/cluster/recommend.ts +59 -0
- package/src/central/cluster/rpc.ts +150 -0
- package/src/central/cluster/view.ts +232 -0
- package/src/central/config.ts +197 -3
- package/src/central/done.ts +96 -0
- package/src/central/events-log.ts +100 -0
- package/src/central/http.ts +1067 -171
- package/src/central/import.ts +318 -0
- package/src/central/main.ts +211 -12
- package/src/central/nodes.ts +314 -0
- package/src/central/oidc.ts +34 -14
- package/src/central/push-target.ts +118 -18
- package/src/central/push.ts +256 -82
- package/src/central/registry.ts +902 -92
- package/src/central/say-stream.ts +128 -0
- package/src/central/session-token-push.ts +233 -0
- package/src/central/speech/openai.ts +21 -14
- package/src/central/speech/openrouter.ts +27 -10
- package/src/central/speech/provider.ts +54 -0
- package/src/central/speech/service.ts +22 -7
- package/src/central/spoke-ws.ts +522 -44
- package/src/central/store-shared.ts +518 -0
- package/src/central/store.ts +572 -189
- package/src/central/token-keys.ts +165 -0
- package/src/config.ts +49 -1
- package/src/index.ts +165 -12
- package/src/protocol.ts +3 -650
- package/src/rpc.ts +27 -2
- package/src/spoke/client.ts +3 -272
- package/src/spoke/conversation.ts +16 -12
- package/src/spoke/deliver.ts +75 -0
- package/src/spoke/enroll.ts +2 -79
- package/src/spoke/env-inject.ts +160 -0
- package/src/spoke/hub-rpc.ts +115 -0
- package/src/spoke/hub-tool.ts +409 -0
- package/src/spoke/launch-source.ts +22 -0
- package/src/spoke/launchers.ts +68 -0
- package/src/spoke/locations.ts +3 -2
- package/src/spoke/node-roots.ts +61 -0
- package/src/spoke/prompt-refs.ts +116 -0
- package/src/spoke/session-tokens.ts +2 -0
- package/src/spoke/spoke.ts +465 -28
- package/src/spoke/taskboard.ts +243 -0
- package/src/spoke/timeline.ts +184 -6
- package/src/spoke/tracker.ts +79 -4
- package/src/status.ts +203 -10
- package/src/tui-commands.ts +13 -6
- package/src/tui-messaging.ts +93 -0
- package/src/tui.ts +15 -0
- package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
- package/web/dist/assets/index-D9zMbxSg.css +1 -0
- package/web/dist/assets/index-db1z4duA.js +20 -0
- package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
- package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
- package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
- package/web/dist/index.html +9 -2
- package/web/dist/sw.js +79 -15
- package/web/dist/assets/index-DClBvfLL.css +0 -1
- 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
|
+
}
|