@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
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@huynle/hub-codex",
|
|
3
|
+
"version": "0.7.1",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "The Codex app-server spoke: attaches to Codex's app-server (its own over stdio, and the shared daemon's socket when present), and the `hub codex` wrapper (internal; shipped inside @huynle/hub, not published on its own).",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// One initialized connection to a Codex app-server: JSON-RPC over a channel, the initialize handshake (with the
|
|
2
|
+
// experimental API, which thread/inject_items and turn/steer's clientUserMessageId need) and the client's name.
|
|
3
|
+
import { JsonRpcPeer, type Channel, type PeerHandlers, type RpcID } from "./jsonrpc.ts"
|
|
4
|
+
import type { AppServerPort } from "./sessions.ts"
|
|
5
|
+
|
|
6
|
+
export interface AppServerConnection extends AppServerPort {
|
|
7
|
+
readonly peer: JsonRpcPeer
|
|
8
|
+
/** Resolves with the reason when the connection closed. */
|
|
9
|
+
readonly closed: Promise<string>
|
|
10
|
+
readonly isClosed: boolean
|
|
11
|
+
close(): void
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface OpenOptions extends PeerHandlers {
|
|
15
|
+
/** The connection's name (keeps its JSON-RPC request IDs apart from another connection's). */
|
|
16
|
+
readonly name: string
|
|
17
|
+
readonly clientVersion: string
|
|
18
|
+
readonly timeoutMs?: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const INIT_TIMEOUT_MS = 20_000
|
|
22
|
+
|
|
23
|
+
/** Opens the connection: initialize, then the initialized notification. Closes the channel when that fails. */
|
|
24
|
+
export async function openAppServer(channel: Channel, options: OpenOptions): Promise<AppServerConnection> {
|
|
25
|
+
const peer = new JsonRpcPeer(channel, options)
|
|
26
|
+
try {
|
|
27
|
+
await peer.request("initialize", { clientInfo: { name: "hub", title: "Hub", version: options.clientVersion }, capabilities: { experimentalApi: true } }, { timeoutMs: options.timeoutMs ?? INIT_TIMEOUT_MS })
|
|
28
|
+
} catch (cause) {
|
|
29
|
+
peer.close()
|
|
30
|
+
throw cause
|
|
31
|
+
}
|
|
32
|
+
peer.notify("initialized")
|
|
33
|
+
return {
|
|
34
|
+
name: options.name,
|
|
35
|
+
peer,
|
|
36
|
+
closed: peer.closed,
|
|
37
|
+
get isClosed() {
|
|
38
|
+
return peer.isClosed
|
|
39
|
+
},
|
|
40
|
+
request: (method: string, params?: unknown) => peer.request(method, params),
|
|
41
|
+
respond: (id: RpcID, result: unknown) => peer.respond(id, result),
|
|
42
|
+
close: () => peer.close(),
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Hub IDs for Codex's native IDs (decision fx04e42c point 2). A thread ID (UUIDv7) maps to the reversible
|
|
2
|
+
// ses_cx<32 hex> (spoke-sdk hubSessionID, namespace "cx"). Other IDs the hub sees (approval requests, items, turns,
|
|
3
|
+
// inbox entries) are plain IDs (protocol wire.ts ID).
|
|
4
|
+
import { randomBytes } from "node:crypto"
|
|
5
|
+
import { hubSessionID, nativeSessionID } from "../../spoke-sdk/src/index.ts"
|
|
6
|
+
|
|
7
|
+
/** The session namespace of Codex in hub session IDs. */
|
|
8
|
+
export const CODEX_NAMESPACE = "cx"
|
|
9
|
+
|
|
10
|
+
const PLAIN_MAX = 300
|
|
11
|
+
|
|
12
|
+
/** The hub session ID of a Codex thread. */
|
|
13
|
+
export const codexSessionID = (threadID: string): string => hubSessionID(CODEX_NAMESPACE, threadID)
|
|
14
|
+
|
|
15
|
+
/** The Codex thread a hub session ID stands for; undefined when it is not a Codex one. */
|
|
16
|
+
export const codexThreadID = (sessionID: string): string | undefined => nativeSessionID(CODEX_NAMESPACE, sessionID)
|
|
17
|
+
|
|
18
|
+
/** `raw` as a plain hub ID (letters, digits and _.:@/+~- only, at most 300 characters); a fresh one when empty. */
|
|
19
|
+
export function plainID(raw: string, prefix = "id"): string {
|
|
20
|
+
const clean = (typeof raw === "string" ? raw : "").replace(/[^A-Za-z0-9_.:@/+~-]/g, "_").slice(0, PLAIN_MAX)
|
|
21
|
+
return clean || `${prefix}_${randomBytes(8).toString("hex")}`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The hub's ID for an app-server request to clients (an approval): the connection's name and the JSON-RPC id, typed
|
|
26
|
+
* (n: number, s: string) so 0 and "0" stay apart. JSON-RPC ids are per connection.
|
|
27
|
+
*/
|
|
28
|
+
export const requestKey = (connection: string, id: number | string): string => plainID(`per_${connection}_${typeof id === "number" ? "n" : "s"}${id}`)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// @huynle/hub-codex: the Codex app-server spoke (design §1.2, §13.3; decision fx04e42c point 5 as corrected by the
|
|
2
|
+
// P7-T0 spike, report v0hsdqhm). The spoke attaches to Codex's app-server as one of its clients: its own
|
|
3
|
+
// `codex app-server` over stdio for the sessions it starts, and the shared daemon's socket when present (the TUI's
|
|
4
|
+
// threads live there). `hub codex` runs the Codex TUI so its threads land on the daemon.
|
|
5
|
+
export * from "./ids.ts"
|
|
6
|
+
export * from "./jsonrpc.ts"
|
|
7
|
+
export * from "./stdio.ts"
|
|
8
|
+
export * from "./websocket.ts"
|
|
9
|
+
export * from "./sessions.ts"
|
|
10
|
+
export * from "./app-server.ts"
|
|
11
|
+
export * from "./spoke.ts"
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// JSON-RPC as Codex's app-server speaks it (codex app-server, P7-T0 spike v0hsdqhm): one JSON object per message,
|
|
2
|
+
// without a "jsonrpc" member. Requests both ways: the client calls methods (thread/start, turn/start …) and the
|
|
3
|
+
// server asks its clients (approvals), each side numbering its own ids. Notifications carry the thread's events.
|
|
4
|
+
// The channel is any transport of whole text messages: lines over a child's stdio (stdio.ts) or WebSocket text frames
|
|
5
|
+
// over the shared daemon's Unix socket (websocket.ts).
|
|
6
|
+
|
|
7
|
+
/** A transport of whole text messages. */
|
|
8
|
+
export interface Channel {
|
|
9
|
+
send(text: string): void
|
|
10
|
+
close(): void
|
|
11
|
+
onMessage(cb: (text: string) => void): void
|
|
12
|
+
/** Called once, when the channel closed (either side). */
|
|
13
|
+
onClose(cb: (reason: string) => void): void
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type RpcID = number | string
|
|
17
|
+
|
|
18
|
+
export class RpcError extends Error {
|
|
19
|
+
constructor(
|
|
20
|
+
message: string,
|
|
21
|
+
readonly code: number,
|
|
22
|
+
readonly method?: string,
|
|
23
|
+
) {
|
|
24
|
+
super(message)
|
|
25
|
+
this.name = "RpcError"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** JSON-RPC's code for a request that could not be sent or answered (this side's own failures). */
|
|
30
|
+
export const LOCAL_ERROR = -32000
|
|
31
|
+
|
|
32
|
+
export interface PeerHandlers {
|
|
33
|
+
onNotification?(method: string, params: unknown): void
|
|
34
|
+
onRequest?(id: RpcID, method: string, params: unknown): void
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const DEFAULT_TIMEOUT_MS = 60_000
|
|
38
|
+
|
|
39
|
+
interface Pending {
|
|
40
|
+
readonly method: string
|
|
41
|
+
resolve(value: unknown): void
|
|
42
|
+
reject(error: Error): void
|
|
43
|
+
readonly timer: ReturnType<typeof setTimeout>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const isObj = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
|
|
47
|
+
const isID = (v: unknown): v is RpcID => typeof v === "number" || typeof v === "string"
|
|
48
|
+
|
|
49
|
+
export class JsonRpcPeer {
|
|
50
|
+
readonly closed: Promise<string>
|
|
51
|
+
#closedReason: string | undefined
|
|
52
|
+
#next = 0
|
|
53
|
+
readonly #pending = new Map<RpcID, Pending>()
|
|
54
|
+
readonly #channel: Channel
|
|
55
|
+
readonly #handlers: PeerHandlers
|
|
56
|
+
|
|
57
|
+
constructor(channel: Channel, handlers: PeerHandlers = {}) {
|
|
58
|
+
this.#channel = channel
|
|
59
|
+
this.#handlers = handlers
|
|
60
|
+
let done!: (reason: string) => void
|
|
61
|
+
this.closed = new Promise((resolve) => (done = resolve))
|
|
62
|
+
channel.onMessage((text) => this.#receive(text))
|
|
63
|
+
channel.onClose((reason) => {
|
|
64
|
+
if (this.#closedReason !== undefined) return
|
|
65
|
+
this.#closedReason = reason || "closed"
|
|
66
|
+
for (const [id, call] of this.#pending) {
|
|
67
|
+
clearTimeout(call.timer)
|
|
68
|
+
call.reject(new RpcError(`The app-server connection closed before it answered ${call.method} (${this.#closedReason})`, LOCAL_ERROR, call.method))
|
|
69
|
+
this.#pending.delete(id)
|
|
70
|
+
}
|
|
71
|
+
done(this.#closedReason)
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
get isClosed(): boolean {
|
|
76
|
+
return this.#closedReason !== undefined
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
request(method: string, params?: unknown, options: { readonly timeoutMs?: number } = {}): Promise<any> {
|
|
80
|
+
if (this.#closedReason !== undefined) return Promise.reject(new RpcError(`The app-server connection is closed (${this.#closedReason})`, LOCAL_ERROR, method))
|
|
81
|
+
const id = ++this.#next
|
|
82
|
+
return new Promise((resolve, reject) => {
|
|
83
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
84
|
+
const timer = setTimeout(() => {
|
|
85
|
+
this.#pending.delete(id)
|
|
86
|
+
reject(new RpcError(`${method}: the app-server did not answer within ${timeoutMs} ms`, LOCAL_ERROR, method))
|
|
87
|
+
}, timeoutMs)
|
|
88
|
+
this.#pending.set(id, { method, resolve, reject, timer })
|
|
89
|
+
this.#send(params === undefined ? { id, method } : { id, method, params })
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
notify(method: string, params?: unknown): void {
|
|
94
|
+
this.#send(params === undefined ? { method } : { method, params })
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Answers a server request. */
|
|
98
|
+
respond(id: RpcID, result: unknown): void {
|
|
99
|
+
this.#send({ id, result })
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
respondError(id: RpcID, code: number, message: string): void {
|
|
103
|
+
this.#send({ id, error: { code, message } })
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
close(): void {
|
|
107
|
+
this.#channel.close()
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
#send(message: object): void {
|
|
111
|
+
if (this.#closedReason !== undefined) return
|
|
112
|
+
try {
|
|
113
|
+
this.#channel.send(JSON.stringify(message))
|
|
114
|
+
} catch {
|
|
115
|
+
// The channel reports its own close; a request then fails there.
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
#receive(text: string): void {
|
|
120
|
+
let message: unknown
|
|
121
|
+
try {
|
|
122
|
+
message = JSON.parse(text)
|
|
123
|
+
} catch {
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
if (!isObj(message)) return
|
|
127
|
+
if (typeof message.method === "string") {
|
|
128
|
+
if (isID(message.id)) this.#handlers.onRequest?.(message.id, message.method, message.params)
|
|
129
|
+
else this.#handlers.onNotification?.(message.method, message.params)
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
if (!isID(message.id)) return
|
|
133
|
+
const call = this.#pending.get(message.id)
|
|
134
|
+
if (!call) return
|
|
135
|
+
this.#pending.delete(message.id)
|
|
136
|
+
clearTimeout(call.timer)
|
|
137
|
+
if (isObj(message.error)) {
|
|
138
|
+
const code = typeof message.error.code === "number" ? message.error.code : LOCAL_ERROR
|
|
139
|
+
call.reject(new RpcError(`${call.method}: ${typeof message.error.message === "string" ? message.error.message : "error"}`, code, call.method))
|
|
140
|
+
} else call.resolve(message.result)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Two connected in-memory channels (tests, and in-process fakes). */
|
|
145
|
+
export function memoryChannelPair(): [Channel, Channel] {
|
|
146
|
+
type End = { listeners: ((t: string) => void)[]; closers: ((r: string) => void)[]; closed: boolean }
|
|
147
|
+
const ends: [End, End] = [
|
|
148
|
+
{ listeners: [], closers: [], closed: false },
|
|
149
|
+
{ listeners: [], closers: [], closed: false },
|
|
150
|
+
]
|
|
151
|
+
const close = (reason: string) => {
|
|
152
|
+
for (const end of ends) {
|
|
153
|
+
if (end.closed) continue
|
|
154
|
+
end.closed = true
|
|
155
|
+
for (const cb of end.closers) cb(reason)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const channel = (self: End, other: End): Channel => ({
|
|
159
|
+
send(text) {
|
|
160
|
+
if (self.closed) throw new Error("channel closed")
|
|
161
|
+
for (const cb of other.listeners) cb(text)
|
|
162
|
+
},
|
|
163
|
+
close: () => close("closed"),
|
|
164
|
+
onMessage: (cb) => void self.listeners.push(cb),
|
|
165
|
+
onClose: (cb) => void self.closers.push(cb),
|
|
166
|
+
})
|
|
167
|
+
return [channel(ends[0], ends[1]), channel(ends[1], ends[0])]
|
|
168
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// `hub codex-spoke serve`: the per-user Codex spoke service until SIGINT/SIGTERM (main.ts aborts io.signal), started
|
|
2
|
+
// in the background by `hub codex` and never typed by hand in normal use. It needs an enrolled machine (the machine
|
|
3
|
+
// token in the spoke's config.json, read-only), keeps a pid file in <hub state dir>/codex/spoke.pid (one service per
|
|
4
|
+
// user: a second one refuses while the first runs) and attaches to the shared Codex daemon's socket when present.
|
|
5
|
+
//
|
|
6
|
+
// Settings (environment): HUB_CODEX_BIN (default `codex` on PATH), HUB_CODEX_DAEMON_SOCKET (another socket, or
|
|
7
|
+
// "off"), HUB_CODEX_CONFIG (Codex config overrides for the app-server the spoke starts, one key=value per line,
|
|
8
|
+
// passed as -c; Codex's files are never written).
|
|
9
|
+
import { spawnSync } from "node:child_process"
|
|
10
|
+
import { openSync, closeSync, readFileSync, rmSync, writeSync } from "node:fs"
|
|
11
|
+
import { configPath } from "../../../src/config.ts"
|
|
12
|
+
import { readSpokeCredentials } from "../../node/src/config.ts"
|
|
13
|
+
import pkg from "../package.json" with { type: "json" }
|
|
14
|
+
import { codexStateDir, ensurePrivateDir, daemonSocketPath, pidFile, readServicePid } from "./paths.ts"
|
|
15
|
+
import { startCodexSpoke } from "./spoke.ts"
|
|
16
|
+
|
|
17
|
+
export const VERSION: string = pkg.version
|
|
18
|
+
|
|
19
|
+
export interface CodexMainIo {
|
|
20
|
+
readonly env: Readonly<Record<string, string | undefined>>
|
|
21
|
+
readonly home: string
|
|
22
|
+
stdout(s: string): void
|
|
23
|
+
stderr(s: string): void
|
|
24
|
+
readonly signal?: AbortSignal
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const CODEX_SPOKE_USAGE = "Usage: hub codex-spoke serve | install [--dry-run] | uninstall [--dry-run] | status"
|
|
28
|
+
|
|
29
|
+
/** `codex --version` → "0.160.0", or "" when it cannot be told. */
|
|
30
|
+
export function codexVersion(bin: string, env: CodexMainIo["env"]): string {
|
|
31
|
+
try {
|
|
32
|
+
const r = spawnSync(bin, ["--version"], { encoding: "utf8", timeout: 5000, env: env as NodeJS.ProcessEnv })
|
|
33
|
+
return /\d+\.\d+\.\d+[\w.+-]*/.exec(r.stdout ?? "")?.[0]?.slice(0, 100) ?? ""
|
|
34
|
+
} catch {
|
|
35
|
+
return ""
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** HUB_CODEX_CONFIG's lines as `-c key=value` arguments. */
|
|
40
|
+
export function configOverrides(value: string | undefined): string[] {
|
|
41
|
+
return (value ?? "")
|
|
42
|
+
.split("\n")
|
|
43
|
+
.map((line) => line.trim())
|
|
44
|
+
.filter((line) => line !== "" && line.includes("="))
|
|
45
|
+
.flatMap((line) => ["-c", line])
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Claims the pid file for this process; false when another live service holds it. */
|
|
49
|
+
function claimPidFile(file: string, env: CodexMainIo["env"], home: string): boolean {
|
|
50
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
51
|
+
try {
|
|
52
|
+
const fd = openSync(file, "wx", 0o600)
|
|
53
|
+
try {
|
|
54
|
+
writeSync(fd, `${process.pid}\n`)
|
|
55
|
+
} finally {
|
|
56
|
+
closeSync(fd)
|
|
57
|
+
}
|
|
58
|
+
return true
|
|
59
|
+
} catch (cause) {
|
|
60
|
+
if ((cause as NodeJS.ErrnoException).code !== "EEXIST") throw cause
|
|
61
|
+
if (readServicePid(env, home) !== undefined) return false
|
|
62
|
+
rmSync(file, { force: true }) // a stale file: its process is gone
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return false
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function releasePidFile(file: string): void {
|
|
69
|
+
try {
|
|
70
|
+
if (readFileSync(file, "utf8").trim() === String(process.pid)) rmSync(file, { force: true })
|
|
71
|
+
} catch {}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function serve(io: CodexMainIo): Promise<number> {
|
|
75
|
+
const credentials = readSpokeCredentials(io.env, io.home)
|
|
76
|
+
if (!credentials) {
|
|
77
|
+
io.stderr(`hub codex-spoke serve: this machine is not enrolled with a hub (no machine token in ${configPath(io.env, io.home)})\n`)
|
|
78
|
+
return 1
|
|
79
|
+
}
|
|
80
|
+
const log = (line: string) => io.stderr(`[hub codex-spoke] ${new Date().toISOString()} ${line}\n`)
|
|
81
|
+
ensurePrivateDir(codexStateDir(io.env, io.home))
|
|
82
|
+
const file = pidFile(io.env, io.home)
|
|
83
|
+
if (!claimPidFile(file, io.env, io.home)) {
|
|
84
|
+
io.stderr(`hub codex-spoke serve: already running (pid ${readServicePid(io.env, io.home) ?? "?"})\n`)
|
|
85
|
+
return 1
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
const codexBin = io.env.HUB_CODEX_BIN || "codex"
|
|
89
|
+
const env: Record<string, string | undefined> = { ...io.env }
|
|
90
|
+
delete env.BUN_BE_BUN
|
|
91
|
+
const daemonSocket = daemonSocketPath(io.env, io.home)
|
|
92
|
+
const running = startCodexSpoke({
|
|
93
|
+
server: credentials.server,
|
|
94
|
+
token: credentials.token,
|
|
95
|
+
harnessVersion: codexVersion(codexBin, env),
|
|
96
|
+
pluginVersion: VERSION,
|
|
97
|
+
directory: io.home,
|
|
98
|
+
codexBin,
|
|
99
|
+
codexArgs: configOverrides(io.env.HUB_CODEX_CONFIG),
|
|
100
|
+
env,
|
|
101
|
+
...(daemonSocket ? { daemonSocket } : {}),
|
|
102
|
+
log,
|
|
103
|
+
})
|
|
104
|
+
log(daemonSocket ? `serving; the Codex daemon socket is ${daemonSocket}` : "serving; not attaching to a Codex daemon")
|
|
105
|
+
await new Promise<void>((resolve) => {
|
|
106
|
+
if (!io.signal) return
|
|
107
|
+
if (io.signal.aborted) return resolve()
|
|
108
|
+
io.signal.addEventListener("abort", () => resolve(), { once: true })
|
|
109
|
+
})
|
|
110
|
+
await running.stop()
|
|
111
|
+
return 0
|
|
112
|
+
} finally {
|
|
113
|
+
releasePidFile(file)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function codexMain(argv: readonly string[], io: CodexMainIo): Promise<number> {
|
|
118
|
+
const [command] = argv
|
|
119
|
+
if (command === "serve") return serve(io)
|
|
120
|
+
io.stderr(`${CODEX_SPOKE_USAGE}\n`)
|
|
121
|
+
return 2
|
|
122
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Where the Codex spoke looks and keeps its files. It only reads Codex's home (CODEX_HOME, default ~/.codex) to find
|
|
2
|
+
// the shared daemon's socket; its own files live in <hub state dir>/codex (the hub state dir is $HUB_STATE_DIR, else
|
|
3
|
+
// ~/.local/state/hub), a private (0700) directory holding the service's pid file (spoke.pid) and its log.
|
|
4
|
+
// $HUB_CODEX_DAEMON_SOCKET names another daemon socket, or "off" for none.
|
|
5
|
+
import { chmodSync, lstatSync, mkdirSync, readFileSync } from "node:fs"
|
|
6
|
+
import { homedir } from "node:os"
|
|
7
|
+
import path from "node:path"
|
|
8
|
+
import { hubEnv, type Env } from "../../../src/env.ts"
|
|
9
|
+
import { APP_DIR, stateBase } from "../../../src/migrate.ts"
|
|
10
|
+
|
|
11
|
+
export function codexHome(env: Env = process.env, home: string = homedir()): string {
|
|
12
|
+
return env.CODEX_HOME || path.join(home, ".codex")
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The shared daemon's control socket, or undefined when attaching to it is turned off. */
|
|
16
|
+
export function daemonSocketPath(env: Env = process.env, home: string = homedir()): string | undefined {
|
|
17
|
+
const override = env.HUB_CODEX_DAEMON_SOCKET
|
|
18
|
+
if (override === "off") return undefined
|
|
19
|
+
return override || path.join(codexHome(env, home), "app-server-control", "app-server-control.sock")
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function codexStateDir(env: Env = process.env, home: string = homedir()): string {
|
|
23
|
+
return path.join(hubEnv(env).HUB_STATE_DIR || path.join(stateBase(home), APP_DIR), "codex")
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const pidFile = (env: Env = process.env, home: string = homedir()) => path.join(codexStateDir(env, home), "spoke.pid")
|
|
27
|
+
|
|
28
|
+
/** A usable process ID: an integer above 1 (0 and negative IDs signal process groups or every process). */
|
|
29
|
+
export const isPid = (n: unknown): n is number => typeof n === "number" && Number.isInteger(n) && n > 1
|
|
30
|
+
|
|
31
|
+
export function isAlive(pid: number): boolean {
|
|
32
|
+
if (!isPid(pid)) return false
|
|
33
|
+
try {
|
|
34
|
+
process.kill(pid, 0)
|
|
35
|
+
return true
|
|
36
|
+
} catch (cause) {
|
|
37
|
+
return (cause as NodeJS.ErrnoException).code === "EPERM"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The running service's pid from its pid file, or undefined when there is none or that process is gone. */
|
|
42
|
+
export function readServicePid(env: Env = process.env, home: string = homedir()): number | undefined {
|
|
43
|
+
let text: string
|
|
44
|
+
try {
|
|
45
|
+
text = readFileSync(pidFile(env, home), "utf8").trim()
|
|
46
|
+
} catch {
|
|
47
|
+
return undefined
|
|
48
|
+
}
|
|
49
|
+
const pid = /^\d+$/.test(text) ? Number(text) : NaN
|
|
50
|
+
return isPid(pid) && isAlive(pid) ? pid : undefined
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Makes `dir` (and its parents) and checks it is a real directory of this user that nobody else can reach: a
|
|
55
|
+
* symlink or someone else's directory is refused, group or other permissions are removed (0700).
|
|
56
|
+
*/
|
|
57
|
+
export function ensurePrivateDir(dir: string): void {
|
|
58
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
59
|
+
const stat = lstatSync(dir)
|
|
60
|
+
if (stat.isSymbolicLink()) throw new Error(`${dir} is a symlink; the Codex spoke needs a real directory there`)
|
|
61
|
+
if (!stat.isDirectory()) throw new Error(`${dir} is not a directory`)
|
|
62
|
+
const uid = process.getuid?.()
|
|
63
|
+
if (uid !== undefined && stat.uid !== uid) throw new Error(`${dir} belongs to another user`)
|
|
64
|
+
if ((stat.mode & 0o777) !== 0o700) chmodSync(dir, 0o700)
|
|
65
|
+
}
|