@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,160 @@
|
|
|
1
|
+
// Session identity for shells (P1-T6, Brain decision 31swaq28): every shell an agent runs through the shell tool
|
|
2
|
+
// gets HUB_URL and HUB_SESSION_TOKEN for its own session, so `hub` there acts as that session; the hub bin dir
|
|
3
|
+
// (where the spoke writes the `hub`/`hubctl` launchers, P1-T10) goes first on PATH for every shell.
|
|
4
|
+
//
|
|
5
|
+
// OpenCode's `shell.create.before` hook has a mutable env but no sessionID; the tool `execute.before` hook has the
|
|
6
|
+
// sessionID and the command. In the shell tool the two run back to back (execute.before, then Shell.create runs
|
|
7
|
+
// create.before, then the tool's own env tweaks and the permission check), so a shell is matched to the most recent
|
|
8
|
+
// pending shell tool call with the same command. Not used: the OPENCODE_SESSION_ID in the create.before env — the
|
|
9
|
+
// shell tool sets it only after our hook, so at that point it is the server process's inherited one (of the
|
|
10
|
+
// session that started this OpenCode, if any), which would hand out the wrong identity.
|
|
11
|
+
//
|
|
12
|
+
// Fail closed: the same command pending from two different sessions gives neither a token; so does a command that
|
|
13
|
+
// another plugin rewrote in a way we cannot see. Known gap: user `!` commands (session.shell) have no tool call, so
|
|
14
|
+
// they get only the stripped env and PATH, no token.
|
|
15
|
+
import { homedir } from "node:os"
|
|
16
|
+
import path from "node:path"
|
|
17
|
+
import { defaultStateDir } from "./localhub"
|
|
18
|
+
|
|
19
|
+
type Env = Record<string, string | undefined>
|
|
20
|
+
|
|
21
|
+
/** What the tool `execute.before`/`execute.after` hooks report (the fields used here). */
|
|
22
|
+
export interface ToolCall {
|
|
23
|
+
readonly tool: string
|
|
24
|
+
readonly sessionID: string
|
|
25
|
+
readonly id: string
|
|
26
|
+
readonly input: unknown
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The shell `create.before` invocation (the fields used here). */
|
|
30
|
+
export interface ShellInvocation {
|
|
31
|
+
readonly command: string
|
|
32
|
+
env: Env
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A session's identity for its shells. */
|
|
36
|
+
export interface Identity {
|
|
37
|
+
readonly token: string
|
|
38
|
+
readonly url?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The OpenCode tool names that run a command in a shell. */
|
|
42
|
+
const SHELL_TOOLS = new Set(["shell", "bash"])
|
|
43
|
+
const HUB_VARS = ["HUB_URL", "HUB_SESSION_TOKEN"] as const
|
|
44
|
+
const DEFAULT_PATH = ["/usr/bin", "/bin", "/usr/sbin", "/sbin"]
|
|
45
|
+
|
|
46
|
+
interface Pending {
|
|
47
|
+
readonly call: ToolCall
|
|
48
|
+
readonly at: number
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const commandOf = (call: ToolCall): string | undefined => {
|
|
52
|
+
const input = call.input as { command?: unknown } | null | undefined
|
|
53
|
+
return input && typeof input === "object" && typeof input.command === "string" ? input.command : undefined
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Pairs shells with the shell tool calls that create them (see the file comment). */
|
|
57
|
+
export class ShellCorrelator {
|
|
58
|
+
readonly #pending: Pending[] = []
|
|
59
|
+
readonly #ttlMs: number
|
|
60
|
+
readonly #max: number
|
|
61
|
+
readonly #now: () => number
|
|
62
|
+
|
|
63
|
+
constructor(options: { ttlMs?: number; max?: number; now?: () => number } = {}) {
|
|
64
|
+
this.#ttlMs = options.ttlMs ?? 30_000
|
|
65
|
+
this.#max = options.max ?? 256
|
|
66
|
+
this.#now = options.now ?? Date.now
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Remembers a shell tool call (other tools are ignored). */
|
|
70
|
+
record(call: ToolCall): void {
|
|
71
|
+
if (!SHELL_TOOLS.has(call.tool) || typeof call.sessionID !== "string" || commandOf(call) === undefined) return
|
|
72
|
+
this.#prune()
|
|
73
|
+
this.#pending.push({ call, at: this.#now() })
|
|
74
|
+
if (this.#pending.length > this.#max) this.#pending.splice(0, this.#pending.length - this.#max)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Forgets a tool call once it finished (its shell, if any, was created long before). */
|
|
78
|
+
done(callID: string): void {
|
|
79
|
+
for (let i = this.#pending.length - 1; i >= 0; i--) if (this.#pending[i]!.call.id === callID) this.#pending.splice(i, 1)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The session of the pending shell tool call with this command, consuming it; undefined when none or when calls
|
|
84
|
+
* from different sessions are pending with it (then all of them are dropped). The command is read now, so input
|
|
85
|
+
* rewrites by hooks that ran after ours count.
|
|
86
|
+
*/
|
|
87
|
+
match(command: string): string | undefined {
|
|
88
|
+
this.#prune()
|
|
89
|
+
const matches = this.#pending.filter((p) => commandOf(p.call) === command)
|
|
90
|
+
if (matches.length === 0) return undefined
|
|
91
|
+
const sessions = new Set(matches.map((p) => p.call.sessionID))
|
|
92
|
+
if (sessions.size > 1) {
|
|
93
|
+
for (const p of matches) this.#pending.splice(this.#pending.indexOf(p), 1)
|
|
94
|
+
return undefined
|
|
95
|
+
}
|
|
96
|
+
this.#pending.splice(this.#pending.indexOf(matches[0]!), 1)
|
|
97
|
+
return matches[0]!.call.sessionID
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
get size(): number {
|
|
101
|
+
this.#prune()
|
|
102
|
+
return this.#pending.length
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
#prune(): void {
|
|
106
|
+
const oldest = this.#now() - this.#ttlMs
|
|
107
|
+
while (this.#pending.length > 0 && this.#pending[0]!.at <= oldest) this.#pending.shift()
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Deletes HUB_URL and HUB_SESSION_TOKEN (an identity inherited from an outer session must never leak). */
|
|
112
|
+
export function stripInheritedHubEnv(env: Env): void {
|
|
113
|
+
for (const name of HUB_VARS) delete env[name]
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Strips inherited HUB_*, sets the session's identity when it has one, and puts `binDir` first on PATH once. */
|
|
117
|
+
export function applyHubEnv(env: Env, identity: Identity | undefined, binDir: string): void {
|
|
118
|
+
stripInheritedHubEnv(env)
|
|
119
|
+
if (identity) {
|
|
120
|
+
if (identity.url) env.HUB_URL = identity.url
|
|
121
|
+
env.HUB_SESSION_TOKEN = identity.token
|
|
122
|
+
}
|
|
123
|
+
const rest = env.PATH === undefined ? DEFAULT_PATH : env.PATH.split(path.delimiter).filter((dir) => dir !== binDir)
|
|
124
|
+
env.PATH = [binDir, ...rest].join(path.delimiter)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Where the `hub`/`hubctl` launchers live: bin under the hub state dir ($HUB_STATE_DIR or ~/.local/state/hub). */
|
|
128
|
+
export function hubBinDir(env: Env = process.env, home: string = homedir()): string {
|
|
129
|
+
return path.join(defaultStateDir(env, home), "bin")
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The hook handlers: tool execute.before/after and shell create.before. They never throw. */
|
|
133
|
+
export function envInjector(options: { correlator: ShellCorrelator; binDir: string; identity: (sessionID: string) => Identity | undefined }) {
|
|
134
|
+
const { correlator, binDir, identity } = options
|
|
135
|
+
return {
|
|
136
|
+
toolBefore(call: ToolCall): void {
|
|
137
|
+
try {
|
|
138
|
+
correlator.record(call)
|
|
139
|
+
} catch (cause) {
|
|
140
|
+
console.error("[hub] shell env: could not record a tool call", cause)
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
toolAfter(call: ToolCall): void {
|
|
144
|
+
try {
|
|
145
|
+
correlator.done(call.id)
|
|
146
|
+
} catch {
|
|
147
|
+
// nothing to forget
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
shellBefore(invocation: ShellInvocation): void {
|
|
151
|
+
try {
|
|
152
|
+
if (!invocation.env || typeof invocation.env !== "object") return
|
|
153
|
+
const sessionID = typeof invocation.command === "string" ? correlator.match(invocation.command) : undefined
|
|
154
|
+
applyHubEnv(invocation.env, sessionID ? identity(sessionID) : undefined, binDir)
|
|
155
|
+
} catch (cause) {
|
|
156
|
+
console.error("[hub] shell env: could not prepare a shell", cause)
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// The TUI's /who and /msg, server side (RPC hubWho and hubMsg, registered in src/index.ts): the hub's agent API as
|
|
2
|
+
// the TUI's CURRENT session, through the shared SDK with that session's own token (Spoke.shellIdentity), the same
|
|
3
|
+
// identity the hub tool uses (src/spoke/hub-tool.ts). The token is only sent to the hub; it never appears in a
|
|
4
|
+
// result or an error (redacted should the hub echo it). Labels are sanitised here, so the TUI shows them as is.
|
|
5
|
+
import { HubClient, HubError, HubTransportError, sanitizeLabel, type AgentMessage, type DirectoryEntry } from "../../packages/sdk/src/index.ts"
|
|
6
|
+
import type { HubToolSpoke } from "./hub-tool"
|
|
7
|
+
|
|
8
|
+
/** The RPC error a failure answers with (src/rpc.ts lists them). */
|
|
9
|
+
export type HubRpcErrorCode = "invalid" | "not_connected" | "unsupported" | "unreachable" | "server_error"
|
|
10
|
+
|
|
11
|
+
export class HubRpcError extends Error {
|
|
12
|
+
constructor(
|
|
13
|
+
readonly code: HubRpcErrorCode,
|
|
14
|
+
message: string,
|
|
15
|
+
) {
|
|
16
|
+
super(message)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** One session in /who (labels sanitised; roles by bare name). */
|
|
21
|
+
export interface WhoRow {
|
|
22
|
+
readonly sessionID: string
|
|
23
|
+
readonly name?: string
|
|
24
|
+
readonly project?: string
|
|
25
|
+
readonly state?: string
|
|
26
|
+
readonly online: boolean
|
|
27
|
+
readonly roles: string[]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface MsgResult {
|
|
31
|
+
readonly id: string
|
|
32
|
+
/** Who it reached: @name, a session ID, or "the user". */
|
|
33
|
+
readonly to: string
|
|
34
|
+
readonly delivery?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** An RPC call waits at most this long for the hub. */
|
|
38
|
+
const TIMEOUT_MS = 15_000
|
|
39
|
+
|
|
40
|
+
type Input = Record<string, unknown>
|
|
41
|
+
|
|
42
|
+
const inputOf = (value: unknown): Input => (value && typeof value === "object" ? (value as Input) : {})
|
|
43
|
+
const optStr = (input: Input, key: string) => (typeof input[key] === "string" && (input[key] as string).trim() ? (input[key] as string).trim() : undefined)
|
|
44
|
+
|
|
45
|
+
/** /msg: sends `text` word for word to `to` as session `sessionID`. */
|
|
46
|
+
export async function hubMsg(spoke: HubToolSpoke, value: unknown): Promise<MsgResult> {
|
|
47
|
+
const input = inputOf(value)
|
|
48
|
+
const sessionID = optStr(input, "sessionID")
|
|
49
|
+
if (!sessionID) throw new HubRpcError("invalid", "Open a session first: /msg sends as the current session.")
|
|
50
|
+
const to = optStr(input, "to")
|
|
51
|
+
if (!to) throw new HubRpcError("invalid", "Who should get it? Usage: /msg @name text")
|
|
52
|
+
const text = input.text
|
|
53
|
+
if (typeof text !== "string" || !text.trim()) throw new HubRpcError("invalid", "What should the message say? Usage: /msg @name text")
|
|
54
|
+
return withClient(spoke, sessionID, "messaging", async (client, signal) => {
|
|
55
|
+
const { message, delivery } = await client.msg(to, text, { signal })
|
|
56
|
+
return { id: message.id, to: recipientText(message), ...(delivery ? { delivery } : {}) }
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** /who: the named and role-holding sessions (optionally one project or one role), as session `sessionID`. */
|
|
61
|
+
export async function hubWho(spoke: HubToolSpoke, value: unknown): Promise<{ sessions: WhoRow[] }> {
|
|
62
|
+
const input = inputOf(value)
|
|
63
|
+
const sessionID = optStr(input, "sessionID")
|
|
64
|
+
if (!sessionID) throw new HubRpcError("invalid", "Open a session first: /who asks the hub as the current session.")
|
|
65
|
+
const project = optStr(input, "project")
|
|
66
|
+
const role = optStr(input, "role")
|
|
67
|
+
return withClient(spoke, sessionID, undefined, async (client, signal) => {
|
|
68
|
+
const { sessions } = await client.who({ ...(project ? { project } : {}), ...(role ? { role } : {}), signal })
|
|
69
|
+
return { sessions: sessions.map(whoRow) }
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Runs `action` with a client holding the session's token; every failure becomes a HubRpcError without the token. */
|
|
74
|
+
async function withClient<T>(
|
|
75
|
+
spoke: HubToolSpoke,
|
|
76
|
+
sessionID: string,
|
|
77
|
+
feature: string | undefined,
|
|
78
|
+
action: (client: HubClient, signal: AbortSignal) => Promise<T>,
|
|
79
|
+
): Promise<T> {
|
|
80
|
+
const identity = spoke.shellIdentity(sessionID)
|
|
81
|
+
if (!identity) throw new HubRpcError("not_connected", "This session has no hub token yet: share it with the hub (/hub) and wait until it is connected.")
|
|
82
|
+
if (!identity.url) throw new HubRpcError("not_connected", "The hub's URL is not known yet; try again shortly.")
|
|
83
|
+
if (feature && !spoke.hubSupports(feature)) throw new HubRpcError("unsupported", `The hub this machine is connected to does not support ${feature}; it needs a newer hub.`)
|
|
84
|
+
const scrub = (text: string) => (identity.token ? text.split(identity.token).join("[redacted]") : text)
|
|
85
|
+
try {
|
|
86
|
+
return await action(new HubClient({ url: identity.url, token: identity.token }), AbortSignal.timeout(TIMEOUT_MS))
|
|
87
|
+
} catch (cause) {
|
|
88
|
+
if (cause instanceof HubError) {
|
|
89
|
+
throw new HubRpcError("server_error", scrub(`${cause.message} (${cause.code})${cause.hint ? `. Hint: ${cause.hint}` : ""}`))
|
|
90
|
+
}
|
|
91
|
+
if (cause instanceof HubTransportError) throw new HubRpcError("unreachable", scrub(cause.message))
|
|
92
|
+
throw new HubRpcError("server_error", scrub(cause instanceof Error ? cause.message : String(cause)))
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function whoRow(entry: DirectoryEntry): WhoRow {
|
|
97
|
+
const name = entry.name ? sanitizeLabel(entry.name) : ""
|
|
98
|
+
const project = entry.project ? sanitizeLabel(entry.project) : ""
|
|
99
|
+
const state = entry.state ? sanitizeLabel(entry.state) : ""
|
|
100
|
+
return {
|
|
101
|
+
sessionID: sanitizeLabel(entry.sessionID),
|
|
102
|
+
...(name ? { name } : {}),
|
|
103
|
+
...(project ? { project } : {}),
|
|
104
|
+
...(state ? { state } : {}),
|
|
105
|
+
online: entry.online === true,
|
|
106
|
+
roles: (entry.roles ?? []).map((r) => sanitizeLabel(r.role)).filter(Boolean),
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function recipientText(message: AgentMessage): string {
|
|
111
|
+
const r = message.recipient
|
|
112
|
+
if (r?.kind === "session") return r.name ? `@${sanitizeLabel(r.name)}` : sanitizeLabel(r.sessionID)
|
|
113
|
+
if (r?.kind === "user") return "the user"
|
|
114
|
+
return sanitizeLabel(message.to)
|
|
115
|
+
}
|