@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,249 @@
|
|
|
1
|
+
// The Codex app-server spoke (P7-T7a; decision fx04e42c point 5 as corrected by spike v0hsdqhm): one HarnessSpoke
|
|
2
|
+
// (harness "codex", evidence "rpc", announcing turnEvents: every turn's end is Codex's own turn/completed) over
|
|
3
|
+
// CodexSessions and up to two app-server connections, each as one client among others:
|
|
4
|
+
//
|
|
5
|
+
// - "own": `codex app-server --listen stdio://`, started on the first create, for the threads the hub starts
|
|
6
|
+
// (thread/start). It is a child of the spoke: when the spoke stops, so do those threads.
|
|
7
|
+
// - "daemon": the shared daemon's Unix socket (CODEX_HOME/app-server-control/app-server-control.sock, WebSocket
|
|
8
|
+
// framing), when present. The TUI and the desktop app keep their threads there; the spoke lists the loaded threads
|
|
9
|
+
// every pollMs (and on thread/started) and subscribes to each new one (thread/resume, excludeTurns). The spoke
|
|
10
|
+
// never starts the daemon: it connects when the socket answers and tries again every pollMs when it does not.
|
|
11
|
+
//
|
|
12
|
+
// Nothing here writes Codex's files (CODEX_HOME): configuration goes in as -c overrides (codexArgs) and environment.
|
|
13
|
+
import { HarnessSpoke, type HarnessSpokeOptions } from "../../spoke-sdk/src/index.ts"
|
|
14
|
+
import { openAppServer, type AppServerConnection } from "./app-server.ts"
|
|
15
|
+
import type { RpcID } from "./jsonrpc.ts"
|
|
16
|
+
import { CodexSessions, type ThreadDescription } from "./sessions.ts"
|
|
17
|
+
import { spawnStdioChannel } from "./stdio.ts"
|
|
18
|
+
import { connectUnixWebSocket } from "./websocket.ts"
|
|
19
|
+
|
|
20
|
+
export const HARNESS = "codex"
|
|
21
|
+
export const DEFAULT_POLL_MS = 2000
|
|
22
|
+
const LIST_PAGES_MAX = 20
|
|
23
|
+
const METHOD_NOT_FOUND = -32601
|
|
24
|
+
|
|
25
|
+
export interface CodexSpokeOptions {
|
|
26
|
+
readonly server: string
|
|
27
|
+
/** The machine token (never logged). */
|
|
28
|
+
readonly token: string
|
|
29
|
+
readonly harnessVersion: string
|
|
30
|
+
readonly pluginVersion: string
|
|
31
|
+
readonly directory: string
|
|
32
|
+
/** The codex executable (default "codex" on PATH, see main.ts). */
|
|
33
|
+
readonly codexBin: string
|
|
34
|
+
/** Arguments before `app-server` (tests: the fake app-server's script). */
|
|
35
|
+
readonly codexBinArgs?: readonly string[]
|
|
36
|
+
/** Arguments after `app-server --listen stdio://` (e.g. -c overrides). */
|
|
37
|
+
readonly codexArgs?: readonly string[]
|
|
38
|
+
/** The own app-server's whole environment. */
|
|
39
|
+
readonly env: Readonly<Record<string, string | undefined>>
|
|
40
|
+
/** The shared daemon's socket; absent: no daemon attach. */
|
|
41
|
+
readonly daemonSocket?: string
|
|
42
|
+
readonly pollMs?: number
|
|
43
|
+
readonly threadDefaults?: Readonly<Record<string, unknown>>
|
|
44
|
+
readonly turnTimeoutMs?: number
|
|
45
|
+
readonly log?: (line: string) => void
|
|
46
|
+
readonly socket?: HarnessSpokeOptions["socket"]
|
|
47
|
+
readonly backoff?: HarnessSpokeOptions["backoff"]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface CodexSpoke {
|
|
51
|
+
readonly spoke: HarnessSpoke
|
|
52
|
+
readonly sessions: CodexSessions
|
|
53
|
+
readonly daemonConnected: boolean
|
|
54
|
+
stop(): Promise<void>
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const message = (cause: unknown) => (cause instanceof Error ? cause.message : String(cause))
|
|
58
|
+
const isObj = (v: unknown): v is Record<string, any> => typeof v === "object" && v !== null && !Array.isArray(v)
|
|
59
|
+
|
|
60
|
+
export function startCodexSpoke(options: CodexSpokeOptions): CodexSpoke {
|
|
61
|
+
const log = options.log ?? (() => {})
|
|
62
|
+
const pollMs = options.pollMs ?? DEFAULT_POLL_MS
|
|
63
|
+
let stopped = false
|
|
64
|
+
let own: Promise<AppServerConnection> | undefined
|
|
65
|
+
let daemon: AppServerConnection | undefined
|
|
66
|
+
let connecting = false
|
|
67
|
+
let discovering = false
|
|
68
|
+
const resuming = new Set<string>()
|
|
69
|
+
|
|
70
|
+
const sessions: CodexSessions = new CodexSessions({
|
|
71
|
+
spoke: { upsertSession: (info) => spoke.upsertSession(info), endSession: (id) => spoke.endSession(id), emit: (event) => spoke.emit(event) },
|
|
72
|
+
createServer: () => ownServer(),
|
|
73
|
+
...(options.threadDefaults ? { threadDefaults: options.threadDefaults } : {}),
|
|
74
|
+
...(options.turnTimeoutMs !== undefined ? { turnTimeoutMs: options.turnTimeoutMs } : {}),
|
|
75
|
+
log,
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
const spoke = new HarnessSpoke({
|
|
79
|
+
server: options.server,
|
|
80
|
+
token: options.token,
|
|
81
|
+
harness: HARNESS,
|
|
82
|
+
harnessVersion: options.harnessVersion,
|
|
83
|
+
evidence: "rpc",
|
|
84
|
+
pluginVersion: options.pluginVersion,
|
|
85
|
+
directory: options.directory,
|
|
86
|
+
kind: "service",
|
|
87
|
+
mode: "always",
|
|
88
|
+
handlers: {
|
|
89
|
+
turn: (req) => sessions.turn(req),
|
|
90
|
+
steer: (req) => sessions.steer(req),
|
|
91
|
+
interrupt: (req) => sessions.interrupt(req),
|
|
92
|
+
stop: (req) => sessions.stop(req),
|
|
93
|
+
permission: (req) => sessions.permission(req),
|
|
94
|
+
create: (req) => sessions.create(req),
|
|
95
|
+
},
|
|
96
|
+
deliver: (req) => sessions.deliver(req),
|
|
97
|
+
features: ["hubPrompt", "launch", "turnEvents"],
|
|
98
|
+
onState: (state, error) => log(`hub connection ${state}${error ? `: ${error}` : ""}`),
|
|
99
|
+
...(options.socket ? { socket: options.socket } : {}),
|
|
100
|
+
...(options.backoff ? { backoff: options.backoff } : {}),
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
/** Routes a connection's notifications and server requests (`conn` is set once initialize answered). */
|
|
104
|
+
function handlers(get: () => AppServerConnection | undefined, onThreadStarted?: (thread: unknown) => void) {
|
|
105
|
+
return {
|
|
106
|
+
onNotification(method: string, params: unknown) {
|
|
107
|
+
const conn = get()
|
|
108
|
+
if (!conn) return
|
|
109
|
+
if (method === "thread/started" && onThreadStarted) onThreadStarted(isObj(params) ? params.thread : undefined)
|
|
110
|
+
sessions.notification(conn, method, params)
|
|
111
|
+
},
|
|
112
|
+
onRequest(id: RpcID, method: string, params: unknown) {
|
|
113
|
+
const conn = get()
|
|
114
|
+
if (!conn) return
|
|
115
|
+
if (sessions.serverRequest(conn, id, method, params)) return
|
|
116
|
+
// On its own app-server the spoke is the only client: nobody else would answer.
|
|
117
|
+
if (conn.name === "own") conn.peer.respondError(id, METHOD_NOT_FOUND, `The hub's Codex spoke does not handle ${method}`)
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function ownServer(): Promise<AppServerConnection> {
|
|
123
|
+
if (stopped) return Promise.reject(new Error("The Codex spoke is stopping"))
|
|
124
|
+
own ??= (async () => {
|
|
125
|
+
const child = spawnStdioChannel({
|
|
126
|
+
command: options.codexBin,
|
|
127
|
+
args: [...(options.codexBinArgs ?? []), "app-server", "--listen", "stdio://", ...(options.codexArgs ?? [])],
|
|
128
|
+
env: options.env,
|
|
129
|
+
log: (line) => log(`codex app-server: ${line}`),
|
|
130
|
+
})
|
|
131
|
+
let conn: AppServerConnection | undefined
|
|
132
|
+
try {
|
|
133
|
+
conn = await openAppServer(child.channel, { name: "own", clientVersion: options.pluginVersion, ...handlers(() => conn) })
|
|
134
|
+
} catch (cause) {
|
|
135
|
+
own = undefined
|
|
136
|
+
child.channel.close()
|
|
137
|
+
throw new Error(`Cannot start codex app-server (${options.codexBin}): ${message(cause)}`)
|
|
138
|
+
}
|
|
139
|
+
const opened = conn
|
|
140
|
+
void opened.closed.then((reason) => {
|
|
141
|
+
log(`own app-server closed: ${reason}`)
|
|
142
|
+
sessions.connectionClosed(opened)
|
|
143
|
+
own = undefined
|
|
144
|
+
})
|
|
145
|
+
return opened
|
|
146
|
+
})()
|
|
147
|
+
return own
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function resume(conn: AppServerConnection, threadID: string): Promise<void> {
|
|
151
|
+
if (resuming.has(threadID) || sessions.hasThread(threadID)) return
|
|
152
|
+
resuming.add(threadID)
|
|
153
|
+
try {
|
|
154
|
+
const res = await conn.request("thread/resume", { threadId: threadID, excludeTurns: true })
|
|
155
|
+
const thread = isObj(res) && isObj(res.thread) ? (res.thread as unknown as ThreadDescription) : undefined
|
|
156
|
+
if (thread && typeof thread.id === "string" && !conn.isClosed && daemon === conn) sessions.attach(conn, { ...thread, cwd: thread.cwd ?? (isObj(res) ? res.cwd : undefined) })
|
|
157
|
+
} catch (cause) {
|
|
158
|
+
log(`thread/resume ${threadID}: ${message(cause)}`)
|
|
159
|
+
} finally {
|
|
160
|
+
resuming.delete(threadID)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function discover(conn: AppServerConnection): Promise<void> {
|
|
165
|
+
if (discovering) return
|
|
166
|
+
discovering = true
|
|
167
|
+
try {
|
|
168
|
+
let cursor: string | undefined
|
|
169
|
+
for (let page = 0; page < LIST_PAGES_MAX; page++) {
|
|
170
|
+
const res = await conn.request("thread/loaded/list", cursor ? { cursor } : {})
|
|
171
|
+
const ids: unknown[] = isObj(res) && Array.isArray(res.data) ? res.data : []
|
|
172
|
+
await Promise.all(ids.filter((id): id is string => typeof id === "string").map((id) => resume(conn, id)))
|
|
173
|
+
cursor = isObj(res) && typeof res.nextCursor === "string" ? res.nextCursor : undefined
|
|
174
|
+
if (!cursor) break
|
|
175
|
+
}
|
|
176
|
+
} catch (cause) {
|
|
177
|
+
if (!conn.isClosed) log(`thread/loaded/list: ${message(cause)}`)
|
|
178
|
+
} finally {
|
|
179
|
+
discovering = false
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function connectDaemon(socketPath: string): Promise<void> {
|
|
184
|
+
if (daemon || connecting || stopped) return
|
|
185
|
+
connecting = true
|
|
186
|
+
try {
|
|
187
|
+
const channel = await connectUnixWebSocket(socketPath, { timeoutMs: Math.max(1000, pollMs) })
|
|
188
|
+
let conn: AppServerConnection | undefined
|
|
189
|
+
conn = await openAppServer(channel, {
|
|
190
|
+
name: "daemon",
|
|
191
|
+
clientVersion: options.pluginVersion,
|
|
192
|
+
...handlers(
|
|
193
|
+
() => conn,
|
|
194
|
+
(thread) => {
|
|
195
|
+
if (conn && isObj(thread) && typeof thread.id === "string") void resume(conn, thread.id)
|
|
196
|
+
},
|
|
197
|
+
),
|
|
198
|
+
})
|
|
199
|
+
if (stopped) return conn.close()
|
|
200
|
+
const opened = conn
|
|
201
|
+
daemon = opened
|
|
202
|
+
log(`attached to the Codex daemon at ${socketPath}`)
|
|
203
|
+
void opened.closed.then((reason) => {
|
|
204
|
+
log(`Codex daemon connection closed: ${reason}`)
|
|
205
|
+
sessions.connectionClosed(opened)
|
|
206
|
+
if (daemon === opened) daemon = undefined
|
|
207
|
+
})
|
|
208
|
+
} catch {
|
|
209
|
+
// No daemon (yet): try again at the next poll.
|
|
210
|
+
} finally {
|
|
211
|
+
connecting = false
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
let timer: ReturnType<typeof setInterval> | undefined
|
|
216
|
+
if (options.daemonSocket) {
|
|
217
|
+
const socketPath = options.daemonSocket
|
|
218
|
+
const tick = async () => {
|
|
219
|
+
await connectDaemon(socketPath)
|
|
220
|
+
if (daemon && !stopped) await discover(daemon)
|
|
221
|
+
}
|
|
222
|
+
timer = setInterval(() => void tick(), pollMs)
|
|
223
|
+
timer.unref?.()
|
|
224
|
+
void tick()
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
spoke.start()
|
|
228
|
+
return {
|
|
229
|
+
spoke,
|
|
230
|
+
sessions,
|
|
231
|
+
get daemonConnected() {
|
|
232
|
+
return daemon !== undefined
|
|
233
|
+
},
|
|
234
|
+
async stop() {
|
|
235
|
+
if (stopped) return
|
|
236
|
+
stopped = true
|
|
237
|
+
if (timer) clearInterval(timer)
|
|
238
|
+
spoke.stop()
|
|
239
|
+
daemon?.close()
|
|
240
|
+
const pending = own
|
|
241
|
+
own = undefined
|
|
242
|
+
if (pending) {
|
|
243
|
+
const conn = await pending.catch(() => undefined)
|
|
244
|
+
conn?.close()
|
|
245
|
+
if (conn) await Promise.race([conn.closed, new Promise((r) => setTimeout(r, 3000))])
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
}
|
|
249
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// A child process's stdio as a channel of lines: `codex app-server --listen stdio://` reads one JSON message per line
|
|
2
|
+
// on stdin and writes one per line on stdout. Its stderr goes to `log` line by line (never parsed); its exit (or a
|
|
3
|
+
// failure to start) closes the channel; closing the channel ends the child (SIGTERM, then SIGKILL after a grace).
|
|
4
|
+
import { spawn } from "node:child_process"
|
|
5
|
+
import type { Channel } from "./jsonrpc.ts"
|
|
6
|
+
|
|
7
|
+
/** Longest stdout line accepted (an app-server message); a longer one ends the child. */
|
|
8
|
+
export const STDIO_MAX_LINE = 16 * 1024 * 1024
|
|
9
|
+
const KILL_GRACE_MS = 2000
|
|
10
|
+
const LOG_LINE_MAX = 2000
|
|
11
|
+
|
|
12
|
+
export interface StdioOptions {
|
|
13
|
+
readonly command: string
|
|
14
|
+
readonly args: readonly string[]
|
|
15
|
+
/** The child's whole environment (nothing is inherited implicitly). */
|
|
16
|
+
readonly env: Readonly<Record<string, string | undefined>>
|
|
17
|
+
readonly cwd?: string
|
|
18
|
+
readonly log?: (line: string) => void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface StdioChild {
|
|
22
|
+
readonly channel: Channel
|
|
23
|
+
/** 0 when the child could not start. */
|
|
24
|
+
readonly pid: number
|
|
25
|
+
readonly exited: Promise<{ code: number | null; signal: string | null }>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function spawnStdioChannel(options: StdioOptions): StdioChild {
|
|
29
|
+
const listeners: ((text: string) => void)[] = []
|
|
30
|
+
const closers: ((reason: string) => void)[] = []
|
|
31
|
+
let closed = false
|
|
32
|
+
const finish = (reason: string) => {
|
|
33
|
+
if (closed) return
|
|
34
|
+
closed = true
|
|
35
|
+
for (const cb of closers) cb(reason)
|
|
36
|
+
}
|
|
37
|
+
const env = Object.fromEntries(Object.entries(options.env).filter(([, v]) => v !== undefined)) as Record<string, string>
|
|
38
|
+
const child = spawn(options.command, [...options.args], { cwd: options.cwd, env, stdio: ["pipe", "pipe", "pipe"] })
|
|
39
|
+
let exitDone!: (v: { code: number | null; signal: string | null }) => void
|
|
40
|
+
const exited = new Promise<{ code: number | null; signal: string | null }>((resolve) => (exitDone = resolve))
|
|
41
|
+
child.on("error", (error) => {
|
|
42
|
+
finish(`cannot run ${options.command}: ${error.message}`)
|
|
43
|
+
exitDone({ code: null, signal: null })
|
|
44
|
+
})
|
|
45
|
+
child.on("exit", (code, signal) => {
|
|
46
|
+
finish(`${options.command} exited (code ${code ?? "none"}${signal ? `, signal ${signal}` : ""})`)
|
|
47
|
+
exitDone({ code, signal })
|
|
48
|
+
})
|
|
49
|
+
child.stdin.on("error", () => {})
|
|
50
|
+
|
|
51
|
+
let out = ""
|
|
52
|
+
child.stdout.setEncoding("utf8")
|
|
53
|
+
child.stdout.on("data", (chunk: string) => {
|
|
54
|
+
out += chunk
|
|
55
|
+
let end: number
|
|
56
|
+
while ((end = out.indexOf("\n")) >= 0) {
|
|
57
|
+
const line = out.slice(0, end).replace(/\r$/, "")
|
|
58
|
+
out = out.slice(end + 1)
|
|
59
|
+
if (line.trim()) for (const cb of listeners) cb(line)
|
|
60
|
+
}
|
|
61
|
+
if (out.length > STDIO_MAX_LINE) child.kill("SIGKILL")
|
|
62
|
+
})
|
|
63
|
+
let err = ""
|
|
64
|
+
child.stderr.setEncoding("utf8")
|
|
65
|
+
child.stderr.on("data", (chunk: string) => {
|
|
66
|
+
err += chunk
|
|
67
|
+
let end: number
|
|
68
|
+
while ((end = err.indexOf("\n")) >= 0) {
|
|
69
|
+
const line = err.slice(0, end)
|
|
70
|
+
err = err.slice(end + 1)
|
|
71
|
+
if (line.trim()) options.log?.(line.slice(0, LOG_LINE_MAX))
|
|
72
|
+
}
|
|
73
|
+
if (err.length > LOG_LINE_MAX) err = ""
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
const stop = () => {
|
|
77
|
+
if (child.exitCode !== null || child.signalCode !== null || !child.pid) return
|
|
78
|
+
child.kill("SIGTERM")
|
|
79
|
+
const timer = setTimeout(() => child.kill("SIGKILL"), KILL_GRACE_MS)
|
|
80
|
+
timer.unref?.()
|
|
81
|
+
void exited.then(() => clearTimeout(timer))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
pid: child.pid ?? 0,
|
|
86
|
+
exited,
|
|
87
|
+
channel: {
|
|
88
|
+
send(text) {
|
|
89
|
+
if (closed) throw new Error("the app-server process has exited")
|
|
90
|
+
child.stdin.write(`${text}\n`)
|
|
91
|
+
},
|
|
92
|
+
close() {
|
|
93
|
+
child.stdin.end()
|
|
94
|
+
stop()
|
|
95
|
+
finish("closed")
|
|
96
|
+
},
|
|
97
|
+
onMessage: (cb) => void listeners.push(cb),
|
|
98
|
+
onClose: (cb) => void closers.push(cb),
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// A minimal WebSocket client (RFC 6455) over a Unix socket. The shared Codex daemon listens on
|
|
2
|
+
// CODEX_HOME/app-server-control/app-server-control.sock and speaks WebSocket there (HTTP Upgrade → 101, then frames;
|
|
3
|
+
// spike v0hsdqhm), so the spoke does the handshake and the framing itself: text messages only, client frames masked,
|
|
4
|
+
// fragmented messages reassembled, pings answered, a close frame (or the socket ending) closes the channel.
|
|
5
|
+
import { createHash, randomBytes } from "node:crypto"
|
|
6
|
+
import { Socket } from "node:net"
|
|
7
|
+
import type { Channel } from "./jsonrpc.ts"
|
|
8
|
+
|
|
9
|
+
const GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
|
10
|
+
/** Largest message this client accepts (the daemon announces 16 MiB unfragmented). */
|
|
11
|
+
export const WS_MAX_MESSAGE = 16 * 1024 * 1024
|
|
12
|
+
const HEAD_MAX = 16 * 1024
|
|
13
|
+
|
|
14
|
+
const OP_CONT = 0x0
|
|
15
|
+
const OP_TEXT = 0x1
|
|
16
|
+
const OP_BINARY = 0x2
|
|
17
|
+
const OP_CLOSE = 0x8
|
|
18
|
+
const OP_PING = 0x9
|
|
19
|
+
const OP_PONG = 0xa
|
|
20
|
+
|
|
21
|
+
/** One client frame (FIN set, masked). */
|
|
22
|
+
export function encodeFrame(opcode: number, payload: Buffer): Buffer {
|
|
23
|
+
const length = payload.length
|
|
24
|
+
const head = length < 126 ? Buffer.alloc(2) : length < 65_536 ? Buffer.alloc(4) : Buffer.alloc(10)
|
|
25
|
+
head[0] = 0x80 | opcode
|
|
26
|
+
if (length < 126) head[1] = 0x80 | length
|
|
27
|
+
else if (length < 65_536) {
|
|
28
|
+
head[1] = 0x80 | 126
|
|
29
|
+
head.writeUInt16BE(length, 2)
|
|
30
|
+
} else {
|
|
31
|
+
head[1] = 0x80 | 127
|
|
32
|
+
head.writeBigUInt64BE(BigInt(length), 2)
|
|
33
|
+
}
|
|
34
|
+
const mask = randomBytes(4)
|
|
35
|
+
const body = Buffer.alloc(length)
|
|
36
|
+
for (let i = 0; i < length; i++) body[i] = payload[i]! ^ mask[i & 3]!
|
|
37
|
+
return Buffer.concat([head, mask, body])
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface WebSocketOptions {
|
|
41
|
+
/** For the connection and the upgrade (default 5 s). */
|
|
42
|
+
readonly timeoutMs?: number
|
|
43
|
+
/** The request path (default "/"). */
|
|
44
|
+
readonly path?: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Connects and upgrades; resolves with a channel of text messages, rejects when either fails. */
|
|
48
|
+
export function connectUnixWebSocket(socketPath: string, options: WebSocketOptions = {}): Promise<Channel> {
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
const key = randomBytes(16).toString("base64")
|
|
51
|
+
const accept = createHash("sha1").update(key + GUID).digest("base64")
|
|
52
|
+
// Listeners first: Bun reports a missing socket synchronously, inside connect() (quirk awi4wb9g).
|
|
53
|
+
const socket = new Socket()
|
|
54
|
+
let upgraded = false
|
|
55
|
+
let head = Buffer.alloc(0)
|
|
56
|
+
const fail = (error: Error) => {
|
|
57
|
+
if (upgraded) return
|
|
58
|
+
clearTimeout(timer)
|
|
59
|
+
socket.destroy()
|
|
60
|
+
reject(error)
|
|
61
|
+
}
|
|
62
|
+
const timer = setTimeout(() => fail(new Error(`No WebSocket upgrade from ${socketPath} within ${options.timeoutMs ?? 5000} ms`)), options.timeoutMs ?? 5000)
|
|
63
|
+
socket.on("error", (error) => fail(error))
|
|
64
|
+
socket.on("close", () => fail(new Error(`${socketPath} closed before the WebSocket upgrade`)))
|
|
65
|
+
socket.on("connect", () => {
|
|
66
|
+
socket.write(
|
|
67
|
+
[`GET ${options.path ?? "/"} HTTP/1.1`, "Host: localhost", "Upgrade: websocket", "Connection: Upgrade", `Sec-WebSocket-Key: ${key}`, "Sec-WebSocket-Version: 13", "", ""].join("\r\n"),
|
|
68
|
+
)
|
|
69
|
+
})
|
|
70
|
+
const onHead = (chunk: Buffer) => {
|
|
71
|
+
head = Buffer.concat([head, chunk])
|
|
72
|
+
const end = head.indexOf("\r\n\r\n")
|
|
73
|
+
if (end < 0) {
|
|
74
|
+
if (head.length > HEAD_MAX) fail(new Error("The WebSocket upgrade response is too long"))
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
const lines = head.subarray(0, end).toString("latin1").split("\r\n")
|
|
78
|
+
const status = /^HTTP\/1\.[01] (\d{3})/.exec(lines[0] ?? "")?.[1]
|
|
79
|
+
const headers = new Map(lines.slice(1).map((l) => [l.slice(0, l.indexOf(":")).trim().toLowerCase(), l.slice(l.indexOf(":") + 1).trim()] as const))
|
|
80
|
+
if (status !== "101") return fail(new Error(`No WebSocket upgrade from ${socketPath} (HTTP ${status ?? "?"})`))
|
|
81
|
+
if (headers.get("sec-websocket-accept") !== accept) return fail(new Error(`Bad Sec-WebSocket-Accept from ${socketPath}`))
|
|
82
|
+
upgraded = true
|
|
83
|
+
clearTimeout(timer)
|
|
84
|
+
socket.off("data", onHead)
|
|
85
|
+
socket.removeAllListeners("close")
|
|
86
|
+
socket.removeAllListeners("error")
|
|
87
|
+
resolve(frameChannel(socket, head.subarray(end + 4)))
|
|
88
|
+
}
|
|
89
|
+
socket.on("data", onHead)
|
|
90
|
+
try {
|
|
91
|
+
socket.connect(socketPath)
|
|
92
|
+
} catch (error) {
|
|
93
|
+
fail(error as Error)
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The channel over an upgraded socket; `rest` is what arrived after the upgrade response. */
|
|
99
|
+
function frameChannel(socket: Socket, rest: Buffer): Channel {
|
|
100
|
+
const listeners: ((text: string) => void)[] = []
|
|
101
|
+
const closers: ((reason: string) => void)[] = []
|
|
102
|
+
let closed = false
|
|
103
|
+
let buffer: Buffer = Buffer.from(rest)
|
|
104
|
+
let fragments: Buffer[] = []
|
|
105
|
+
let fragmentsLength = 0
|
|
106
|
+
let fragmentText = false
|
|
107
|
+
|
|
108
|
+
const finish = (reason: string) => {
|
|
109
|
+
if (closed) return
|
|
110
|
+
closed = true
|
|
111
|
+
socket.destroy()
|
|
112
|
+
for (const cb of closers) cb(reason)
|
|
113
|
+
}
|
|
114
|
+
const write = (opcode: number, payload: Buffer) => {
|
|
115
|
+
if (!closed && !socket.destroyed) socket.write(encodeFrame(opcode, payload))
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const parse = () => {
|
|
119
|
+
for (;;) {
|
|
120
|
+
if (buffer.length < 2) return
|
|
121
|
+
const fin = (buffer[0]! & 0x80) !== 0
|
|
122
|
+
const opcode = buffer[0]! & 0x0f
|
|
123
|
+
const masked = (buffer[1]! & 0x80) !== 0
|
|
124
|
+
let length = buffer[1]! & 0x7f
|
|
125
|
+
let offset = 2
|
|
126
|
+
if (length === 126) {
|
|
127
|
+
if (buffer.length < 4) return
|
|
128
|
+
length = buffer.readUInt16BE(2)
|
|
129
|
+
offset = 4
|
|
130
|
+
} else if (length === 127) {
|
|
131
|
+
if (buffer.length < 10) return
|
|
132
|
+
const big = buffer.readBigUInt64BE(2)
|
|
133
|
+
if (big > BigInt(WS_MAX_MESSAGE)) return finish("message too large")
|
|
134
|
+
length = Number(big)
|
|
135
|
+
offset = 10
|
|
136
|
+
}
|
|
137
|
+
if (length > WS_MAX_MESSAGE) return finish("message too large")
|
|
138
|
+
const maskAt = offset
|
|
139
|
+
if (masked) offset += 4
|
|
140
|
+
if (buffer.length < offset + length) return
|
|
141
|
+
let payload = buffer.subarray(offset, offset + length)
|
|
142
|
+
if (masked) {
|
|
143
|
+
const mask = buffer.subarray(maskAt, maskAt + 4)
|
|
144
|
+
payload = Buffer.from(payload.map((b, i) => b ^ mask[i & 3]!))
|
|
145
|
+
}
|
|
146
|
+
buffer = buffer.subarray(offset + length)
|
|
147
|
+
switch (opcode) {
|
|
148
|
+
case OP_PING:
|
|
149
|
+
write(OP_PONG, Buffer.from(payload))
|
|
150
|
+
break
|
|
151
|
+
case OP_PONG:
|
|
152
|
+
break
|
|
153
|
+
case OP_CLOSE:
|
|
154
|
+
write(OP_CLOSE, Buffer.from(payload.subarray(0, 2)))
|
|
155
|
+
return finish(payload.length > 2 ? payload.subarray(2).toString("utf8") : "closed by the server")
|
|
156
|
+
case OP_TEXT:
|
|
157
|
+
case OP_BINARY:
|
|
158
|
+
case OP_CONT: {
|
|
159
|
+
if (opcode !== OP_CONT) {
|
|
160
|
+
fragments = []
|
|
161
|
+
fragmentsLength = 0
|
|
162
|
+
fragmentText = opcode === OP_TEXT
|
|
163
|
+
}
|
|
164
|
+
fragments.push(Buffer.from(payload))
|
|
165
|
+
fragmentsLength += payload.length
|
|
166
|
+
if (fragmentsLength > WS_MAX_MESSAGE) return finish("message too large")
|
|
167
|
+
if (fin) {
|
|
168
|
+
const message = Buffer.concat(fragments)
|
|
169
|
+
fragments = []
|
|
170
|
+
fragmentsLength = 0
|
|
171
|
+
if (fragmentText) for (const cb of listeners) cb(message.toString("utf8"))
|
|
172
|
+
}
|
|
173
|
+
break
|
|
174
|
+
}
|
|
175
|
+
default:
|
|
176
|
+
return finish(`unknown WebSocket opcode ${opcode}`)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
socket.on("data", (chunk: Buffer) => {
|
|
182
|
+
buffer = buffer.length ? Buffer.concat([buffer, chunk]) : chunk
|
|
183
|
+
parse()
|
|
184
|
+
})
|
|
185
|
+
socket.on("close", () => finish("connection closed"))
|
|
186
|
+
socket.on("error", (error) => finish(error.message))
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
send(text) {
|
|
190
|
+
if (closed) throw new Error("WebSocket closed")
|
|
191
|
+
write(OP_TEXT, Buffer.from(text, "utf8"))
|
|
192
|
+
},
|
|
193
|
+
close() {
|
|
194
|
+
if (closed) return
|
|
195
|
+
write(OP_CLOSE, Buffer.from([0x03, 0xe8]))
|
|
196
|
+
socket.end()
|
|
197
|
+
finish("closed")
|
|
198
|
+
},
|
|
199
|
+
onMessage(cb) {
|
|
200
|
+
listeners.push(cb)
|
|
201
|
+
// Frames that came with the upgrade response wait for the first listener.
|
|
202
|
+
if (listeners.length === 1 && buffer.length) parse()
|
|
203
|
+
},
|
|
204
|
+
onClose: (cb) => void closers.push(cb),
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// `hub codex [codex args…]`: runs Codex so the hub's Codex spoke can see its threads, without touching Codex's files
|
|
2
|
+
// (CODEX_HOME, default ~/.codex). It makes sure the per-user spoke service runs (starting `hub codex-spoke serve` in
|
|
3
|
+
// the background when none does), then runs codex with the shared app-server daemon turned on for this run
|
|
4
|
+
// (`--enable daemon_auto_start`: the TUI's threads live on the daemon, where the spoke attaches) and the user's
|
|
5
|
+
// arguments, its exit code passed through. On a machine that is not enrolled (no machine token) or when the service
|
|
6
|
+
// cannot be started, Codex runs as usual without the hub, and the command says why.
|
|
7
|
+
//
|
|
8
|
+
// Settings (environment): HUB_CODEX_BIN (default `codex` on PATH); see main.ts for the service's.
|
|
9
|
+
import { spawn } from "node:child_process"
|
|
10
|
+
import { closeSync, openSync } from "node:fs"
|
|
11
|
+
import path from "node:path"
|
|
12
|
+
import { configPath } from "../../../src/config.ts"
|
|
13
|
+
import { readSpokeCredentials } from "../../node/src/config.ts"
|
|
14
|
+
import { codexStateDir, ensurePrivateDir, readServicePid } from "./paths.ts"
|
|
15
|
+
|
|
16
|
+
const SERVICE_START_MS = 8000
|
|
17
|
+
/** Codex's own flag (a feature toggle for this run only; nothing is written). */
|
|
18
|
+
export const DAEMON_ARGS = ["--enable", "daemon_auto_start"] as const
|
|
19
|
+
|
|
20
|
+
type Env = Readonly<Record<string, string | undefined>>
|
|
21
|
+
|
|
22
|
+
export interface Program {
|
|
23
|
+
readonly execPath: string
|
|
24
|
+
readonly entry: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface CodexIo {
|
|
28
|
+
readonly env: Env
|
|
29
|
+
readonly home: string
|
|
30
|
+
stderr(s: string): void
|
|
31
|
+
/** What runs the hub CLI (the service runs it too). */
|
|
32
|
+
readonly program: Program
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Starts `hub codex-spoke serve` detached, logging to spoke.log in the spoke's state directory. */
|
|
36
|
+
function spawnService(io: CodexIo): void {
|
|
37
|
+
const dir = codexStateDir(io.env, io.home)
|
|
38
|
+
ensurePrivateDir(dir)
|
|
39
|
+
const log = openSync(path.join(dir, "spoke.log"), "a", 0o600)
|
|
40
|
+
try {
|
|
41
|
+
const child = spawn(io.program.execPath, [io.program.entry, "codex-spoke", "serve"], { detached: true, stdio: ["ignore", log, log], env: { ...io.env, BUN_BE_BUN: "1" } })
|
|
42
|
+
child.on("error", () => {})
|
|
43
|
+
child.unref()
|
|
44
|
+
} finally {
|
|
45
|
+
closeSync(log)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** True when the spoke service runs (started here when needed); else false with the reason on stderr. */
|
|
50
|
+
async function ensureService(io: CodexIo): Promise<boolean> {
|
|
51
|
+
if (!readSpokeCredentials(io.env, io.home)) {
|
|
52
|
+
io.stderr(`hub codex: this machine is not enrolled with a hub (no machine token in ${configPath(io.env, io.home)}); running Codex without the hub\n`)
|
|
53
|
+
return false
|
|
54
|
+
}
|
|
55
|
+
if (readServicePid(io.env, io.home) !== undefined) return true
|
|
56
|
+
spawnService(io)
|
|
57
|
+
const deadline = Date.now() + SERVICE_START_MS
|
|
58
|
+
while (Date.now() < deadline) {
|
|
59
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
60
|
+
if (readServicePid(io.env, io.home) !== undefined) return true
|
|
61
|
+
}
|
|
62
|
+
io.stderr(`hub codex: the Codex spoke did not start (see ${path.join(codexStateDir(io.env, io.home), "spoke.log")}); running Codex without the hub\n`)
|
|
63
|
+
return false
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const SIGNALS: Record<string, number> = { SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGKILL: 9, SIGTERM: 15 }
|
|
67
|
+
|
|
68
|
+
/** Runs `bin` with the terminal; resolves with its exit code (128 + signal number when a signal ended it). */
|
|
69
|
+
function spawnCodex(bin: string, args: readonly string[], env: Env, stderr: (s: string) => void): Promise<number> {
|
|
70
|
+
return new Promise((resolve) => {
|
|
71
|
+
const child = spawn(bin, args, { stdio: "inherit", env: env as NodeJS.ProcessEnv })
|
|
72
|
+
// The terminal sends Ctrl-C to Codex too: it decides what that means; this process keeps waiting.
|
|
73
|
+
const ignore = () => {}
|
|
74
|
+
const forward = () => child.kill("SIGTERM")
|
|
75
|
+
process.on("SIGINT", ignore)
|
|
76
|
+
process.on("SIGTERM", forward)
|
|
77
|
+
const done = (code: number) => {
|
|
78
|
+
process.off("SIGINT", ignore)
|
|
79
|
+
process.off("SIGTERM", forward)
|
|
80
|
+
resolve(code)
|
|
81
|
+
}
|
|
82
|
+
child.on("error", (cause) => {
|
|
83
|
+
stderr(`hub codex: cannot run ${bin}: ${cause.message}\n`)
|
|
84
|
+
done(127)
|
|
85
|
+
})
|
|
86
|
+
child.on("exit", (code, signal) => done(code ?? (signal ? 128 + (SIGNALS[signal] ?? 0) : 1)))
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function runCodex(args: readonly string[], io: CodexIo): Promise<number> {
|
|
91
|
+
let hub = false
|
|
92
|
+
try {
|
|
93
|
+
hub = await ensureService(io)
|
|
94
|
+
} catch (cause) {
|
|
95
|
+
io.stderr(`hub codex: ${cause instanceof Error ? cause.message : String(cause)}; running Codex without the hub\n`)
|
|
96
|
+
}
|
|
97
|
+
const env: Record<string, string | undefined> = { ...io.env }
|
|
98
|
+
delete env.BUN_BE_BUN
|
|
99
|
+
return spawnCodex(io.env.HUB_CODEX_BIN || "codex", hub ? [...DAEMON_ARGS, ...args] : [...args], env, io.stderr)
|
|
100
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@huynle/hub-node",
|
|
3
|
+
"version": "0.7.1",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "`hub node`: the per-machine daemon that prepares, collects and cleans up launch workspaces (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
|
+
}
|