@huynle/hub 0.6.1 → 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
package/src/rpc.ts
CHANGED
|
@@ -15,11 +15,13 @@ const empty = obj({})
|
|
|
15
15
|
const errorData = { type: "object" } as const
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
|
-
* not_connected: no live connection
|
|
19
|
-
* unavailable: spoke not running in this process ·
|
|
18
|
+
* not_connected: no live connection (or the session has no hub token yet) · invalid: bad input ·
|
|
19
|
+
* unreachable: server could not be reached · unavailable: spoke not running in this process ·
|
|
20
|
+
* server_error: the hub refused the request · unsupported: the hub lacks the feature (messaging).
|
|
20
21
|
*/
|
|
21
22
|
const errors = {
|
|
22
23
|
not_connected: errorData,
|
|
24
|
+
unsupported: errorData,
|
|
23
25
|
invalid: errorData,
|
|
24
26
|
unreachable: errorData,
|
|
25
27
|
unavailable: errorData,
|
|
@@ -90,6 +92,29 @@ const methods = {
|
|
|
90
92
|
},
|
|
91
93
|
/** Conversation mode for a session in this process (same path as the server's "conversation" op). */
|
|
92
94
|
conversation: { input: obj({ sessionID: str, on: bool }, ["sessionID", "on"]), output: obj({ agent: str }, ["agent"]), errors },
|
|
95
|
+
/**
|
|
96
|
+
* /who: the hub's named and role-holding sessions (optionally one project or one role), asked as the TUI's
|
|
97
|
+
* current session (its own hub token). Labels are sanitised.
|
|
98
|
+
*/
|
|
99
|
+
hubWho: {
|
|
100
|
+
input: obj({ sessionID: str, project: str, role: str }, ["sessionID"]),
|
|
101
|
+
output: obj(
|
|
102
|
+
{
|
|
103
|
+
sessions: {
|
|
104
|
+
type: "array",
|
|
105
|
+
items: obj({ sessionID: str, name: str, project: str, state: str, online: bool, roles: { type: "array", items: str } }, ["sessionID", "online", "roles"]),
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
["sessions"],
|
|
109
|
+
),
|
|
110
|
+
errors,
|
|
111
|
+
},
|
|
112
|
+
/** /msg: sends `text` word for word to `to` as the TUI's current session (its own hub token). */
|
|
113
|
+
hubMsg: {
|
|
114
|
+
input: obj({ sessionID: str, to: str, text: str }, ["sessionID", "to", "text"]),
|
|
115
|
+
output: obj({ id: str, to: str, delivery: str }, ["id", "to"]),
|
|
116
|
+
errors,
|
|
117
|
+
},
|
|
93
118
|
} as const
|
|
94
119
|
|
|
95
120
|
export const HubRpc = Rpc.define({ id: "hub", methods, events: {} })
|
package/src/spoke/client.ts
CHANGED
|
@@ -1,272 +1,3 @@
|
|
|
1
|
-
// The spoke's
|
|
2
|
-
//
|
|
3
|
-
|
|
4
|
-
encode,
|
|
5
|
-
MAX_FRAME,
|
|
6
|
-
parseServerMessage,
|
|
7
|
-
PROTOCOL_VERSION,
|
|
8
|
-
type Hello,
|
|
9
|
-
type ResultFor,
|
|
10
|
-
type ServerRequest,
|
|
11
|
-
type SpokeMessage,
|
|
12
|
-
type SpokeRequest,
|
|
13
|
-
type Welcome,
|
|
14
|
-
} from "../protocol"
|
|
15
|
-
|
|
16
|
-
export type ConnectionState = "off" | "connecting" | "connected" | "revoked" | "error"
|
|
17
|
-
|
|
18
|
-
export interface Backoff {
|
|
19
|
-
readonly minMs: number
|
|
20
|
-
readonly maxMs: number
|
|
21
|
-
readonly random?: () => number
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/** Minimal WebSocket surface used here (Bun's WebSocket accepts `{ headers }` as a 2nd argument). */
|
|
25
|
-
export interface SocketLike {
|
|
26
|
-
readonly readyState: number
|
|
27
|
-
onopen: ((ev: unknown) => void) | null
|
|
28
|
-
onmessage: ((ev: { data: unknown }) => void) | null
|
|
29
|
-
onclose: ((ev: { code?: number; reason?: string }) => void) | null
|
|
30
|
-
onerror: ((ev: unknown) => void) | null
|
|
31
|
-
send(data: string): void
|
|
32
|
-
close(code?: number, reason?: string): void
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export interface ConnectionOptions {
|
|
36
|
-
readonly server: string
|
|
37
|
-
readonly token: string
|
|
38
|
-
/** Hello fields, computed per attempt so mode/directory are current. */
|
|
39
|
-
readonly hello: () => Omit<Hello, "t" | "v">
|
|
40
|
-
/** Executes a server request; a thrown error becomes `{ ok: false, error }`. */
|
|
41
|
-
readonly handle: (req: ServerRequest) => Promise<unknown>
|
|
42
|
-
readonly onWelcome?: (welcome: Welcome) => void
|
|
43
|
-
readonly onState?: (state: ConnectionState, error?: string) => void
|
|
44
|
-
readonly backoff?: Backoff
|
|
45
|
-
readonly requestTimeoutMs?: number
|
|
46
|
-
readonly socket?: (url: string, headers: Record<string, string>) => SocketLike
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export function backoffDelay(attempt: number, backoff: Backoff): number {
|
|
50
|
-
const base = Math.min(backoff.maxMs, backoff.minMs * 2 ** Math.min(attempt, 30))
|
|
51
|
-
const random = (backoff.random ?? Math.random)()
|
|
52
|
-
return Math.round(base * (0.5 + 0.5 * random))
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export function wsURL(server: string): string {
|
|
56
|
-
return `${server.replace(/\/+$/, "").replace(/^http/, "ws")}/spoke/ws`
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const defaultSocket = (url: string, headers: Record<string, string>): SocketLike =>
|
|
60
|
-
new (WebSocket as unknown as new (url: string, options: { headers: Record<string, string> }) => SocketLike)(url, { headers })
|
|
61
|
-
|
|
62
|
-
const message = (cause: unknown) => (cause instanceof Error ? cause.message : String(cause))
|
|
63
|
-
|
|
64
|
-
interface Pending {
|
|
65
|
-
resolve: (value: unknown) => void
|
|
66
|
-
reject: (error: Error) => void
|
|
67
|
-
timer: ReturnType<typeof setTimeout>
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export class SpokeConnection {
|
|
71
|
-
state: ConnectionState = "off"
|
|
72
|
-
lastError?: string
|
|
73
|
-
publicURL?: string
|
|
74
|
-
machineID?: string
|
|
75
|
-
|
|
76
|
-
private socket?: SocketLike
|
|
77
|
-
private welcomed = false
|
|
78
|
-
private stopped = true
|
|
79
|
-
private attempt = 0
|
|
80
|
-
private retry?: ReturnType<typeof setTimeout>
|
|
81
|
-
private nextID = 0
|
|
82
|
-
private readonly pending = new Map<string, Pending>()
|
|
83
|
-
private readonly backoff: Backoff
|
|
84
|
-
|
|
85
|
-
constructor(private readonly options: ConnectionOptions) {
|
|
86
|
-
this.backoff = options.backoff ?? { minMs: 1000, maxMs: 60_000 }
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
start(): void {
|
|
90
|
-
if (!this.stopped) return
|
|
91
|
-
this.stopped = false
|
|
92
|
-
this.open()
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
stop(): void {
|
|
96
|
-
this.stopped = true
|
|
97
|
-
clearTimeout(this.retry)
|
|
98
|
-
this.drop(1000, "stop")
|
|
99
|
-
this.failPending("disconnected")
|
|
100
|
-
this.setState("off")
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/** Reconnects at once (e.g. after a local hub was started) instead of waiting out the backoff. */
|
|
104
|
-
retryNow(): void {
|
|
105
|
-
if (this.stopped || this.state === "connected" || this.state === "revoked" || this.socket) return
|
|
106
|
-
clearTimeout(this.retry)
|
|
107
|
-
this.attempt = 0
|
|
108
|
-
this.open()
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/** Sends a frame once the server has welcomed us. Returns false when it could not be sent. */
|
|
112
|
-
send(frame: SpokeMessage): boolean {
|
|
113
|
-
if (!this.socket || !this.welcomed || this.socket.readyState !== 1) return false
|
|
114
|
-
try {
|
|
115
|
-
let text = encode(frame)
|
|
116
|
-
if (text.length > MAX_FRAME) {
|
|
117
|
-
// The hub drops the connection on an oversize frame; answer a request with an error instead.
|
|
118
|
-
if (frame.t !== "res") return false
|
|
119
|
-
text = encode({ t: "res", id: frame.id, ok: false, error: `Result too large to send (${text.length} characters, limit ${MAX_FRAME})` })
|
|
120
|
-
}
|
|
121
|
-
this.socket.send(text)
|
|
122
|
-
return true
|
|
123
|
-
} catch {
|
|
124
|
-
return false
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
request<R extends SpokeRequest>(req: R): Promise<ResultFor[R["op"]]> {
|
|
129
|
-
const id = `s${++this.nextID}`
|
|
130
|
-
return new Promise((resolve, reject) => {
|
|
131
|
-
if (this.state !== "connected") return reject(new Error("Not connected to the hub"))
|
|
132
|
-
const timer = setTimeout(() => {
|
|
133
|
-
this.pending.delete(id)
|
|
134
|
-
reject(new Error("The hub timed out"))
|
|
135
|
-
}, this.options.requestTimeoutMs ?? 15_000)
|
|
136
|
-
this.pending.set(id, { resolve: resolve as (v: unknown) => void, reject, timer })
|
|
137
|
-
if (!this.send({ t: "req", id, req })) {
|
|
138
|
-
clearTimeout(timer)
|
|
139
|
-
this.pending.delete(id)
|
|
140
|
-
reject(new Error("Not connected to the hub"))
|
|
141
|
-
}
|
|
142
|
-
})
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
private open(): void {
|
|
146
|
-
if (this.stopped) return
|
|
147
|
-
this.setState(this.state === "error" ? "error" : "connecting")
|
|
148
|
-
let socket: SocketLike
|
|
149
|
-
try {
|
|
150
|
-
socket = (this.options.socket ?? defaultSocket)(wsURL(this.options.server), {
|
|
151
|
-
Authorization: `Bearer ${this.options.token}`,
|
|
152
|
-
})
|
|
153
|
-
} catch (cause) {
|
|
154
|
-
this.fail(message(cause))
|
|
155
|
-
return
|
|
156
|
-
}
|
|
157
|
-
this.socket = socket
|
|
158
|
-
this.welcomed = false
|
|
159
|
-
socket.onopen = () => {
|
|
160
|
-
try {
|
|
161
|
-
socket.send(encode({ t: "hello", v: PROTOCOL_VERSION, ...this.options.hello() }))
|
|
162
|
-
} catch (cause) {
|
|
163
|
-
this.lastError = message(cause)
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
socket.onmessage = (ev) => this.receive(ev.data)
|
|
167
|
-
socket.onerror = () => {
|
|
168
|
-
this.lastError = this.welcomed ? "Connection error" : "Could not connect to the hub"
|
|
169
|
-
}
|
|
170
|
-
socket.onclose = (ev) => {
|
|
171
|
-
if (this.socket !== socket) return
|
|
172
|
-
this.socket = undefined
|
|
173
|
-
const wasWelcomed = this.welcomed
|
|
174
|
-
this.welcomed = false
|
|
175
|
-
this.failPending("disconnected")
|
|
176
|
-
if (this.stopped || this.state === "revoked") return
|
|
177
|
-
this.fail(wasWelcomed ? `Connection closed${ev.reason ? `: ${ev.reason}` : ""}` : (this.lastError ?? "Could not connect"))
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
private receive(data: unknown): void {
|
|
182
|
-
const text = typeof data === "string" ? data : data instanceof ArrayBuffer ? Buffer.from(data).toString("utf8") : String(data)
|
|
183
|
-
const frame = parseServerMessage(text)
|
|
184
|
-
if (!frame) return
|
|
185
|
-
switch (frame.t) {
|
|
186
|
-
case "welcome":
|
|
187
|
-
this.welcomed = true
|
|
188
|
-
this.attempt = 0
|
|
189
|
-
this.publicURL = frame.publicURL
|
|
190
|
-
this.machineID = frame.machineID
|
|
191
|
-
this.lastError = undefined
|
|
192
|
-
this.setState("connected")
|
|
193
|
-
try {
|
|
194
|
-
this.options.onWelcome?.(frame)
|
|
195
|
-
} catch (cause) {
|
|
196
|
-
console.error("[hub] welcome handler failed", cause)
|
|
197
|
-
}
|
|
198
|
-
return
|
|
199
|
-
case "bye":
|
|
200
|
-
if (frame.reason === "revoked") {
|
|
201
|
-
this.stopped = true
|
|
202
|
-
clearTimeout(this.retry)
|
|
203
|
-
this.lastError = "This machine was revoked on the hub"
|
|
204
|
-
this.setState("revoked")
|
|
205
|
-
this.drop(1000, "revoked")
|
|
206
|
-
this.failPending("revoked")
|
|
207
|
-
}
|
|
208
|
-
return
|
|
209
|
-
case "res": {
|
|
210
|
-
const pending = this.pending.get(frame.id)
|
|
211
|
-
if (!pending) return
|
|
212
|
-
this.pending.delete(frame.id)
|
|
213
|
-
clearTimeout(pending.timer)
|
|
214
|
-
if (frame.ok) pending.resolve(frame.result)
|
|
215
|
-
else pending.reject(new Error(frame.error ?? "Request failed"))
|
|
216
|
-
return
|
|
217
|
-
}
|
|
218
|
-
case "req":
|
|
219
|
-
void this.answer(frame.id, frame.req)
|
|
220
|
-
return
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
private async answer(id: string, req: ServerRequest): Promise<void> {
|
|
225
|
-
let reply: SpokeMessage
|
|
226
|
-
try {
|
|
227
|
-
reply = { t: "res", id, ok: true, result: await this.options.handle(req) }
|
|
228
|
-
} catch (cause) {
|
|
229
|
-
reply = { t: "res", id, ok: false, error: message(cause) }
|
|
230
|
-
}
|
|
231
|
-
this.send(reply)
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
private fail(error: string): void {
|
|
235
|
-
this.lastError = error
|
|
236
|
-
this.setState("error")
|
|
237
|
-
if (this.stopped) return
|
|
238
|
-
clearTimeout(this.retry)
|
|
239
|
-
this.retry = setTimeout(() => this.open(), backoffDelay(this.attempt++, this.backoff))
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
private drop(code: number, reason: string): void {
|
|
243
|
-
const socket = this.socket
|
|
244
|
-
this.socket = undefined
|
|
245
|
-
this.welcomed = false
|
|
246
|
-
if (!socket) return
|
|
247
|
-
socket.onopen = socket.onmessage = socket.onerror = socket.onclose = null
|
|
248
|
-
try {
|
|
249
|
-
socket.close(code, reason)
|
|
250
|
-
} catch {
|
|
251
|
-
// already closed
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
private failPending(reason: string): void {
|
|
256
|
-
for (const [id, pending] of this.pending) {
|
|
257
|
-
clearTimeout(pending.timer)
|
|
258
|
-
pending.reject(new Error(`Hub ${reason}`))
|
|
259
|
-
this.pending.delete(id)
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
private setState(state: ConnectionState): void {
|
|
264
|
-
if (state === this.state) return
|
|
265
|
-
this.state = state
|
|
266
|
-
try {
|
|
267
|
-
this.options.onState?.(state, this.lastError)
|
|
268
|
-
} catch {
|
|
269
|
-
// observers must not break the connection
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
}
|
|
1
|
+
// The spoke's connection to the central server lives in the spoke SDK (packages/spoke-sdk); re-exported by relative
|
|
2
|
+
// path so the published package and compiled binaries need no workspace link.
|
|
3
|
+
export * from "../../packages/spoke-sdk/src/connection.ts"
|
|
@@ -5,12 +5,8 @@
|
|
|
5
5
|
|
|
6
6
|
export const VOICE_AGENT = "voice"
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
export
|
|
10
|
-
const PROMPT_SOURCES: readonly unknown[] = [PROMPT_SOURCE, "opencode-hub", "opencode-voice"]
|
|
11
|
-
/** True for prompt metadata written by the hub, under either name. */
|
|
12
|
-
export const isHubPrompt = (metadata: unknown): boolean =>
|
|
13
|
-
typeof metadata === "object" && metadata !== null && PROMPT_SOURCES.includes((metadata as { source?: unknown }).source)
|
|
8
|
+
// PROMPT_SOURCE and isHubPrompt live in the spoke SDK (a harness-neutral marker for the hub's prompts).
|
|
9
|
+
export { PROMPT_SOURCE, isHubPrompt } from "../../packages/spoke-sdk/src/prompt-source.ts"
|
|
14
10
|
|
|
15
11
|
export const VOICE_AGENT_DESCRIPTION =
|
|
16
12
|
"Voice front-end: short spoken replies; delegates real work to background subagents. Used by Hub conversation mode."
|
|
@@ -34,11 +30,19 @@ How to work:
|
|
|
34
30
|
- Delegate multi-step work. A routine worker handles deterministic MCP work, short research and routine checks on your fast model. A complex worker handles coding, debugging, architecture, risky operations or work requiring substantial judgment on the full work model named below.
|
|
35
31
|
- Call the subagent tool with the delegate agent named in the note below (use "general" if none is named), background: true, and a complete, self-contained prompt with the goal, relevant context, constraints, and what to report back. Require that the worker's final assistant message must contain only one or two plain-spoken sentences that can be read directly to the user. Put technical detail in tool output or progress before that final message.
|
|
36
32
|
- Never run a subagent in the foreground (always pass background: true): a foreground subagent blocks the conversation and the user cannot talk to you until it ends.
|
|
37
|
-
-
|
|
38
|
-
- When the user asks
|
|
39
|
-
-
|
|
33
|
+
- Give every worker a short description of two to four words, like "Deploy check" or "Changelog update": it is the task's name on your task board and in spoken announcements.
|
|
34
|
+
- Parallel work: start one worker per independent task. When the user asks for several separate things, launch all their workers at once with several subagent tool calls in the same reply, each with background: true and its own self-contained prompt. Don't bundle unrelated tasks into one worker and don't wait for one to finish before starting the next. Example: "Check if the deploy went through and also update the changelog" → say "On it, two workers: one checks the deploy, one updates the changelog." and make two subagent calls in that reply.
|
|
35
|
+
- New user messages can arrive while work is running; treat them as steering. For a correction, status request or related follow-up on the same active task, continue its subagent with the existing sessionID instead of starting another one. Example: while "Deploy check" runs, the user says "check staging too" → continue that worker: call the subagent tool with its sessionID and the new instruction. Start a fresh worker after that task completes or for an unrelated task.
|
|
36
|
+
- When the user asks how it's going or what's running, answer in a sentence from the task board below (your workers, their state, last activity and results); it is live, so don't call a tool to check.
|
|
37
|
+
- Successful worker results are spoken directly, and the hub also announces when a worker fails, needs a permission or asks a question (with the task's name when several run). Do not repeat a completed worker's result or those announcements. Speak again only if the user must decide something, or a warning or next step genuinely needs explanation.
|
|
38
|
+
- To stop one task ("cancel the deploy one", "stop the changelog worker"), call hub_task_stop with that worker's sessionID from the task board; the other workers keep going. For "stop all the workers" or "pause everything", call it with all: true. It never stops you, and you can confirm in a few words. The stopped worker's cancellation notice arrives as a message afterwards: don't reply to it, you already confirmed.
|
|
40
39
|
- If the user says stop, cancel or never mind, stop: start no new work and confirm briefly.`
|
|
41
40
|
|
|
41
|
+
/** The tool the plugin adds for stopping one background worker (or all of them) by voice. */
|
|
42
|
+
export const STOP_TOOL = "hub_task_stop"
|
|
43
|
+
export const STOP_TOOL_DESCRIPTION =
|
|
44
|
+
"Stop a background worker (subagent session) you started, e.g. when the user says \"cancel the deploy one\" or \"stop that task\". Pass its sessionID from your task board, or all: true to stop all your running workers. It never stops you or sessions you did not start."
|
|
45
|
+
|
|
42
46
|
export function delegateNote(subagent: string): string {
|
|
43
47
|
return `Delegate agent on this machine: when you call the subagent tool, use agent "${subagent}" and always set background: true. Require its final assistant message to contain only one or two plain-spoken sentences for direct speech.`
|
|
44
48
|
}
|
|
@@ -51,9 +55,9 @@ export function workModelNote(workModel: string): string {
|
|
|
51
55
|
return `For a routine worker, omit model so it inherits your fast model. For a complex worker, set model: "${workModel}". Complex means coding, debugging, architecture, risky operations or work requiring substantial judgment.`
|
|
52
56
|
}
|
|
53
57
|
|
|
54
|
-
/** System parts the context hook adds: the delegate note for the voice agent, the whole prompt when conversation mode runs on another agent. */
|
|
55
|
-
export function orchestratorParts(input: { agent: string; conversation: boolean; subagent: string; workModel?: string }): string[] {
|
|
56
|
-
const notes = [delegateNote(input.subagent), ...(input.workModel ? [workModelNote(input.workModel)] : [])]
|
|
58
|
+
/** System parts the context hook adds: the delegate note (and the task board) for the voice agent, the whole prompt first when conversation mode runs on another agent. */
|
|
59
|
+
export function orchestratorParts(input: { agent: string; conversation: boolean; subagent: string; workModel?: string; taskBoard?: string }): string[] {
|
|
60
|
+
const notes = [delegateNote(input.subagent), ...(input.workModel ? [workModelNote(input.workModel)] : []), ...(input.taskBoard ? [input.taskBoard] : [])]
|
|
57
61
|
if (input.agent === VOICE_AGENT) return notes
|
|
58
62
|
if (input.conversation) return [ORCHESTRATOR_PROMPT, ...notes]
|
|
59
63
|
return []
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Hub message delivery (the deliver op): how a message is admitted into an OpenCode session, the id OpenCode
|
|
2
|
+
// gets for it, the metadata it carries, who put each inbox item there, and applying each message once.
|
|
3
|
+
//
|
|
4
|
+
// Messages go in through OpenCode's native session.synthetic with a client id (decision 52aqsopo): OpenCode
|
|
5
|
+
// keeps them in the session's inbox (busy sessions, open forms, interrupts) and deduplicates the id, even across
|
|
6
|
+
// restarts. The model sees only the text (the hub-rendered envelope), never the metadata. An agent's prompt (kind
|
|
7
|
+
// "prompt") gets its provenance envelope here, from the hub's meta, and is the hub's in the inbox (never a human turn).
|
|
8
|
+
import { createHash } from "node:crypto"
|
|
9
|
+
import type { DeliverMeta, Delivered, DeliveryMode } from "../protocol"
|
|
10
|
+
import { PROMPT_SOURCE } from "./conversation"
|
|
11
|
+
|
|
12
|
+
export interface DeliveryPlan {
|
|
13
|
+
/** How OpenCode admits it into a busy session: after the running turn (queue) or into it (steer). */
|
|
14
|
+
readonly delivery: DeliveryMode
|
|
15
|
+
/** Whether OpenCode starts a turn for it when the session is idle. */
|
|
16
|
+
readonly resume: boolean
|
|
17
|
+
/** What the spoke reports to the hub. */
|
|
18
|
+
readonly delivered: Delivered
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface RecipientState {
|
|
22
|
+
/** A turn runs (or OpenCode reports the session busy). */
|
|
23
|
+
readonly busy: boolean
|
|
24
|
+
/** A form (question) is open in the session. */
|
|
25
|
+
readonly formOpen: boolean
|
|
26
|
+
/** The session is a subagent's (it has a parent). */
|
|
27
|
+
readonly subagent: boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* How to admit a message. Busy recipients get plain messages after the running turn and asks into it, unless
|
|
32
|
+
* the hub chose (`meta.delivery`, e.g. urgent: steer). Idle recipients get a turn, always steered: a queued wake
|
|
33
|
+
* would come after the reply to items already waiting (a primer, wake:false messages) with a model call of its own
|
|
34
|
+
* (quirk hzmcqwpj), a steered one joins them in one call. With a form open OpenCode holds the message until the
|
|
35
|
+
* form is answered ("retained").
|
|
36
|
+
*
|
|
37
|
+
* `wake:false` and subagent sessions are admitted without starting a turn ("queued"). They default to steer: a
|
|
38
|
+
* queued item gets its own model call when a turn ends (that would wake the session), while a steered one
|
|
39
|
+
* without resume waits for the next turn and arrives before its prompt (or joins a turn already running).
|
|
40
|
+
*/
|
|
41
|
+
export function planDelivery(req: { readonly wake: boolean; readonly meta: DeliverMeta }, state: RecipientState): DeliveryPlan {
|
|
42
|
+
const resume = req.wake && !state.subagent
|
|
43
|
+
const chosen = req.meta.delivery ?? (!resume || req.meta.kind === "ask" ? "steer" : "queue")
|
|
44
|
+
// Idle: no running turn to queue behind; steered, the wake shares its model call with what already waits.
|
|
45
|
+
const delivery = resume && !state.busy && !state.formOpen ? "steer" : chosen
|
|
46
|
+
return { delivery, resume, delivered: deliveredAs(delivery, resume, state) }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function deliveredAs(delivery: DeliveryMode, resume: boolean, state: RecipientState): Delivered {
|
|
50
|
+
if (!resume) return "queued"
|
|
51
|
+
if (state.formOpen) return "retained"
|
|
52
|
+
if (state.busy) return delivery === "steer" ? "steer" : "queued"
|
|
53
|
+
return "turn"
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** OpenCode message ids: "msg_" and id characters. */
|
|
57
|
+
const OPENCODE_ID = /^msg_[A-Za-z0-9]{1,200}$/
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The id OpenCode gets for a hub message: the hub's own `msg_…` id, else a stable `msg_hub…` id derived from it,
|
|
61
|
+
* so every retry of the same message reuses one id (OpenCode applies it once).
|
|
62
|
+
*/
|
|
63
|
+
export function harnessMessageID(messageID: string): string {
|
|
64
|
+
if (OPENCODE_ID.test(messageID)) return messageID
|
|
65
|
+
return `msg_hub${createHash("sha256").update(messageID).digest("hex").slice(0, 40)}`
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** metadata stored with a delivered message (for timelines; the model does not see it). */
|
|
69
|
+
export function deliverMetadata(req: { readonly messageID: string; readonly meta: DeliverMeta }): Record<string, unknown> {
|
|
70
|
+
const { kind, from, to, replyTo, grant } = req.meta
|
|
71
|
+
return { source: PROMPT_SOURCE, messageID: req.messageID, kind, from, to, ...(replyTo !== undefined ? { replyTo } : {}), ...(grant !== undefined ? { grant } : {}) }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// The prompt envelope, InboxOrigins and DeliveryLedger live in the spoke SDK (harness-neutral).
|
|
75
|
+
export { deliverText, DeliveryLedger, InboxOrigins, renderPromptEnvelope, type Admission } from "../../packages/spoke-sdk/src/delivery.ts"
|
package/src/spoke/enroll.ts
CHANGED
|
@@ -1,79 +1,2 @@
|
|
|
1
|
-
// Device-code enrollment
|
|
2
|
-
|
|
3
|
-
import type { EnrollPoll, EnrollStart } from "../protocol"
|
|
4
|
-
|
|
5
|
-
export type EnrollOutcome =
|
|
6
|
-
| { readonly status: "approved"; readonly machineID: string; readonly machineToken: string }
|
|
7
|
-
| { readonly status: "denied" | "expired" | "cancelled" }
|
|
8
|
-
|
|
9
|
-
export interface EnrollOptions {
|
|
10
|
-
readonly signal?: AbortSignal
|
|
11
|
-
readonly fetch?: typeof fetch
|
|
12
|
-
readonly now?: () => number
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
const REQUEST_TIMEOUT_MS = 15_000
|
|
16
|
-
|
|
17
|
-
async function post(server: string, path: string, body: unknown, options: EnrollOptions): Promise<unknown> {
|
|
18
|
-
const doFetch = options.fetch ?? fetch
|
|
19
|
-
const signals = [AbortSignal.timeout(REQUEST_TIMEOUT_MS), ...(options.signal ? [options.signal] : [])]
|
|
20
|
-
const response = await doFetch(`${server.replace(/\/+$/, "")}${path}`, {
|
|
21
|
-
method: "POST",
|
|
22
|
-
headers: { "content-type": "application/json" },
|
|
23
|
-
body: JSON.stringify(body),
|
|
24
|
-
signal: AbortSignal.any(signals),
|
|
25
|
-
})
|
|
26
|
-
if (!response.ok) throw new Error(`${path} failed: HTTP ${response.status}`)
|
|
27
|
-
return response.json()
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function isStart(v: any): v is EnrollStart {
|
|
31
|
-
return (
|
|
32
|
-
typeof v === "object" &&
|
|
33
|
-
v !== null &&
|
|
34
|
-
typeof v.userCode === "string" &&
|
|
35
|
-
typeof v.deviceCode === "string" &&
|
|
36
|
-
typeof v.approveURL === "string" &&
|
|
37
|
-
typeof v.intervalMs === "number" &&
|
|
38
|
-
typeof v.expiresAt === "number"
|
|
39
|
-
)
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export async function startEnrollment(server: string, machineName: string, options: EnrollOptions = {}): Promise<EnrollStart> {
|
|
43
|
-
const body = await post(server, "/spoke/enroll", { machineName }, options)
|
|
44
|
-
if (!isStart(body)) throw new Error("Invalid enrollment reply from the hub")
|
|
45
|
-
return body
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
const sleep = (ms: number, signal?: AbortSignal) =>
|
|
49
|
-
new Promise<void>((resolve) => {
|
|
50
|
-
const timer = setTimeout(done, ms)
|
|
51
|
-
function done() {
|
|
52
|
-
clearTimeout(timer)
|
|
53
|
-
signal?.removeEventListener("abort", done)
|
|
54
|
-
resolve()
|
|
55
|
-
}
|
|
56
|
-
signal?.addEventListener("abort", done, { once: true })
|
|
57
|
-
})
|
|
58
|
-
|
|
59
|
-
/** Polls until approved, denied, expired, or aborted. Transient errors are retried until expiry. */
|
|
60
|
-
export async function pollEnrollment(server: string, start: EnrollStart, options: EnrollOptions = {}): Promise<EnrollOutcome> {
|
|
61
|
-
const now = options.now ?? Date.now
|
|
62
|
-
const interval = Math.max(1, start.intervalMs)
|
|
63
|
-
for (;;) {
|
|
64
|
-
if (options.signal?.aborted) return { status: "cancelled" }
|
|
65
|
-
if (now() > start.expiresAt) return { status: "expired" }
|
|
66
|
-
await sleep(interval, options.signal)
|
|
67
|
-
if (options.signal?.aborted) return { status: "cancelled" }
|
|
68
|
-
let reply: Partial<EnrollPoll> & Record<string, unknown>
|
|
69
|
-
try {
|
|
70
|
-
reply = (await post(server, "/spoke/enroll/poll", { deviceCode: start.deviceCode }, options)) as typeof reply
|
|
71
|
-
} catch {
|
|
72
|
-
continue
|
|
73
|
-
}
|
|
74
|
-
if (reply?.status === "approved" && typeof reply.machineID === "string" && typeof reply.machineToken === "string") {
|
|
75
|
-
return { status: "approved", machineID: reply.machineID, machineToken: reply.machineToken }
|
|
76
|
-
}
|
|
77
|
-
if (reply?.status === "denied" || reply?.status === "expired") return { status: reply.status }
|
|
78
|
-
}
|
|
79
|
-
}
|
|
1
|
+
// Device-code enrollment lives in the spoke SDK (packages/spoke-sdk); re-exported by relative path.
|
|
2
|
+
export * from "../../packages/spoke-sdk/src/enroll.ts"
|