@cocorograph/hub-agent 0.8.114 → 0.8.116
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/package.json +2 -1
- package/src/chat-removal-guard.mjs +1 -1
- package/src/chat-session-starter.mjs +3 -2
- package/src/codex-binary.mjs +43 -27
- package/src/config.mjs +9 -4
- package/src/dispatch-scheduler.mjs +13 -11
- package/src/grok-acp-client.mjs +130 -0
- package/src/grok-binary.mjs +50 -0
- package/src/grok-stream-bridge.mjs +756 -0
- package/src/hub-bundle.mjs +183 -26
- package/src/live-sessions.mjs +12 -11
- package/src/main.mjs +310 -63
- package/src/review-event-store.mjs +1 -1
- package/src/session-names.mjs +21 -12
- package/src/sibling-inject.mjs +12 -8
- package/src/ws-client.mjs +26 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cocorograph/hub-agent",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.116",
|
|
4
4
|
"description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの Claude / Codex チャットと tmux セッションを中継する。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "UNLICENSED",
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"@anthropic-ai/claude-agent-sdk": "^0.3.158",
|
|
35
35
|
"commander": "^12.1.0",
|
|
36
36
|
"pino": "^9.0.0",
|
|
37
|
+
"smol-toml": "^1.9.1",
|
|
37
38
|
"ws": "^8.18.0"
|
|
38
39
|
},
|
|
39
40
|
"repository": {
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* **消える直前の dir を cwd とする会話**が新しく生まれ、削除後には cwd が存在しない会話として
|
|
6
6
|
* 残る(「送ったら起きる」方式にしたので、この窓は利用者の送信 1 回で簡単に開く)。
|
|
7
7
|
*
|
|
8
|
-
* ⚠️ 検査は **attach の定義側
|
|
8
|
+
* ⚠️ 検査は **attach の定義側 3 箇所**(claude-stream-bridge / codex-stream-bridge / grok-stream-bridge)に置く。
|
|
9
9
|
* 呼び出し元は現時点で 4 箇所あるが、経路ごとに掛けると経路が増えたときに片方だけ守られる。
|
|
10
10
|
* 会話が生まれる場所は attach だけなので、そこで 1 回見れば足りる。
|
|
11
11
|
*
|
|
@@ -17,7 +17,8 @@ export async function ensureChatSession({ cwd, cliKind, bridges, logger, resolve
|
|
|
17
17
|
const existing = describeLiveSessionsUnion(bridges, { cwd, cliKind })[0]
|
|
18
18
|
if (existing) return { ok: true, session: existing, cwd }
|
|
19
19
|
const bridge = cliKind === "claude" ? bridges?.claudeBridge
|
|
20
|
-
: cliKind === "codex" ? bridges?.codexBridge
|
|
20
|
+
: cliKind === "codex" ? bridges?.codexBridge
|
|
21
|
+
: cliKind === "grok" ? bridges?.grokBridge : null
|
|
21
22
|
if (!bridge?.attach) throw new Error(`chat bridge unavailable: ${cliKind}`)
|
|
22
23
|
const prepared = await prepareChatWorkspace({ cwd, cliKind, waitForSync: false, logger })
|
|
23
24
|
cwd = prepared.cwd
|
|
@@ -71,7 +72,7 @@ export async function ensureChatSession({ cwd, cliKind, bridges, logger, resolve
|
|
|
71
72
|
const concurrent = describeLiveSessionsUnion(bridges, { cwd, cliKind })[0]
|
|
72
73
|
if (concurrent) return { ok: true, session: concurrent, cwd }
|
|
73
74
|
const stream_id = `auto-${randomUUID()}`
|
|
74
|
-
if (cliKind === "codex") await bridge.attach({ stream_id, cwd })
|
|
75
|
+
if (cliKind === "codex" || cliKind === "grok") await bridge.attach({ stream_id, cwd })
|
|
75
76
|
else bridge.attach({ ...resolveClaudeOptions?.(), stream_id, cwd })
|
|
76
77
|
const session = describeLiveSessionsUnion(bridges, { cwd, cliKind })[0]
|
|
77
78
|
return { ok: true, session, cwd }
|
package/src/codex-binary.mjs
CHANGED
|
@@ -3,6 +3,44 @@ import { execFileSync } from "node:child_process"
|
|
|
3
3
|
import os from "node:os"
|
|
4
4
|
import path from "node:path"
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* CLI 共通の実行可能判定と PATH 探索。探索順と unavailable の診断は各 resolver が持つ。
|
|
8
|
+
*/
|
|
9
|
+
export function createBinaryLookup({ platform, accessSyncImpl, statSyncImpl, execFileSyncImpl }) {
|
|
10
|
+
const pathApi = platform === "win32" ? path.win32 : path
|
|
11
|
+
const executable = (candidate) => {
|
|
12
|
+
try {
|
|
13
|
+
if (!statSyncImpl(candidate).isFile()) return false
|
|
14
|
+
accessSyncImpl(candidate, fsConstants.X_OK)
|
|
15
|
+
return true
|
|
16
|
+
} catch {
|
|
17
|
+
return false
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const findOnPath = (command) => {
|
|
22
|
+
try {
|
|
23
|
+
const lookup = platform === "win32" ? "where" : "which"
|
|
24
|
+
const output = execFileSyncImpl(lookup, [command], { encoding: "utf8" })
|
|
25
|
+
const resolved = String(output)
|
|
26
|
+
.split(/\r?\n/)
|
|
27
|
+
.map((line) => line.trim())
|
|
28
|
+
.find(Boolean)
|
|
29
|
+
if (resolved) {
|
|
30
|
+
return {
|
|
31
|
+
available: true,
|
|
32
|
+
path: pathApi.isAbsolute(resolved) ? resolved : pathApi.resolve(resolved),
|
|
33
|
+
source: "path",
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
} catch {
|
|
37
|
+
// 候補が無い場合のフォールバックは呼び出し側が決める。
|
|
38
|
+
}
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
41
|
+
return { pathApi, executable, findOnPath }
|
|
42
|
+
}
|
|
43
|
+
|
|
6
44
|
/**
|
|
7
45
|
* Cockpit が起動する Codex の共通 resolver。
|
|
8
46
|
*
|
|
@@ -17,16 +55,9 @@ export function resolveCodexBinary({
|
|
|
17
55
|
statSyncImpl = statSync,
|
|
18
56
|
execFileSyncImpl = execFileSync,
|
|
19
57
|
} = {}) {
|
|
20
|
-
const pathApi
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
if (!statSyncImpl(candidate).isFile()) return false
|
|
24
|
-
accessSyncImpl(candidate, fsConstants.X_OK)
|
|
25
|
-
return true
|
|
26
|
-
} catch {
|
|
27
|
-
return false
|
|
28
|
-
}
|
|
29
|
-
}
|
|
58
|
+
const { pathApi, executable, findOnPath } = createBinaryLookup({
|
|
59
|
+
platform, accessSyncImpl, statSyncImpl, execFileSyncImpl,
|
|
60
|
+
})
|
|
30
61
|
|
|
31
62
|
const explicit = typeof env.HUB_CODEX_BIN === "string" ? env.HUB_CODEX_BIN.trim() : ""
|
|
32
63
|
if (explicit) {
|
|
@@ -50,23 +81,8 @@ export function resolveCodexBinary({
|
|
|
50
81
|
return { available: true, path: pluginPath, source: "plugin-appserver" }
|
|
51
82
|
}
|
|
52
83
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const output = execFileSyncImpl(lookup, ["codex"], { encoding: "utf8" })
|
|
56
|
-
const resolved = String(output)
|
|
57
|
-
.split(/\r?\n/)
|
|
58
|
-
.map((line) => line.trim())
|
|
59
|
-
.find(Boolean)
|
|
60
|
-
if (resolved) {
|
|
61
|
-
return {
|
|
62
|
-
available: true,
|
|
63
|
-
path: pathApi.isAbsolute(resolved) ? resolved : pathApi.resolve(resolved),
|
|
64
|
-
source: "path",
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
} catch {
|
|
68
|
-
// PATH に無い場合は unavailable。plugin は次回呼び出し時に再評価される。
|
|
69
|
-
}
|
|
84
|
+
const fromPath = findOnPath("codex")
|
|
85
|
+
if (fromPath) return fromPath
|
|
70
86
|
|
|
71
87
|
return { available: false, path: null, source: "path", error: "codex CLI is unavailable" }
|
|
72
88
|
}
|
package/src/config.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import { randomUUID } from "node:crypto"
|
|
|
14
14
|
import { execFile, execFileSync } from "node:child_process"
|
|
15
15
|
import { promisify } from "node:util"
|
|
16
16
|
import { resolveCodexBinary } from "./codex-binary.mjs"
|
|
17
|
+
import { resolveGrokBinary } from "./grok-binary.mjs"
|
|
17
18
|
|
|
18
19
|
const _execFileP = promisify(execFile)
|
|
19
20
|
|
|
@@ -60,16 +61,16 @@ export function detectPlatform({ execImpl } = {}) {
|
|
|
60
61
|
}
|
|
61
62
|
|
|
62
63
|
/** このホストで起動可否を検出する CLI 種別の候補。 */
|
|
63
|
-
export const CLI_CANDIDATES = ["claude", "codex"]
|
|
64
|
+
export const CLI_CANDIDATES = ["claude", "codex", "grok"]
|
|
64
65
|
|
|
65
66
|
/**
|
|
66
67
|
* 起動可能な CLI 種別 (capability) を検出する。Cockpit が「この hub-agent で
|
|
67
68
|
* どの CLI を起動できるか」を知るため、起動時に 1 回呼んで enroll/hello payload で
|
|
68
|
-
* Hub に広告する (`CockpitAgent.available_clis`)。Codex は共通 resolver、その他は
|
|
69
|
+
* Hub に広告する (`CockpitAgent.available_clis`)。Codex / Grok は共通 resolver、その他は
|
|
69
70
|
* which/where が成功したものだけ返す。
|
|
70
71
|
*
|
|
71
|
-
* @param {{candidates?:string[], exec?:(cmd:string,args:string[])=>Promise<any>, _resolveCodexBinary?:()=>{available:boolean}}} [opts]
|
|
72
|
-
* exec / _resolveCodexBinary はテスト差し替え用。
|
|
72
|
+
* @param {{candidates?:string[], exec?:(cmd:string,args:string[])=>Promise<any>, _resolveCodexBinary?:()=>{available:boolean}, _resolveGrokBinary?:()=>{available:boolean}}} [opts]
|
|
73
|
+
* exec / _resolveCodexBinary / _resolveGrokBinary はテスト差し替え用。
|
|
73
74
|
* @returns {Promise<string[]>} 例: ["claude", "codex"]
|
|
74
75
|
*/
|
|
75
76
|
export async function detectAvailableClis(opts = {}) {
|
|
@@ -82,6 +83,10 @@ export async function detectAvailableClis(opts = {}) {
|
|
|
82
83
|
if ((opts._resolveCodexBinary || resolveCodexBinary)().available) found.push(cli)
|
|
83
84
|
continue
|
|
84
85
|
}
|
|
86
|
+
if (cli === "grok") {
|
|
87
|
+
if ((opts._resolveGrokBinary || resolveGrokBinary)().available) found.push(cli)
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
85
90
|
try {
|
|
86
91
|
await exec(lookup, [cli])
|
|
87
92
|
found.push(cli)
|
|
@@ -65,6 +65,7 @@ function chatMode(type) {
|
|
|
65
65
|
if (typeof type !== "string") return null
|
|
66
66
|
if (type === "claude.attach" || type.startsWith("claude.")) return "claude"
|
|
67
67
|
if (type === "codex.attach" || type.startsWith("codex.")) return "codex"
|
|
68
|
+
if (type === "grok.attach" || type.startsWith("grok.")) return "grok"
|
|
68
69
|
return null
|
|
69
70
|
}
|
|
70
71
|
|
|
@@ -93,7 +94,7 @@ export function attachLaneKey(mode, msg, defaultCwd = "") {
|
|
|
93
94
|
const cwd = (typeof msg?.cwd === "string" ? msg.cwd.trim() : "") || defaultCwd
|
|
94
95
|
if (cwd) return `${mode}:cwd:${cwd}`
|
|
95
96
|
const resumeId =
|
|
96
|
-
mode === "claude"
|
|
97
|
+
mode === "claude" || mode === "grok"
|
|
97
98
|
? msg?.resume_session_id
|
|
98
99
|
: msg?.resume_thread_id || msg?.thread_id
|
|
99
100
|
if (typeof resumeId === "string" && resumeId) return `${mode}:resume:${resumeId}`
|
|
@@ -106,7 +107,7 @@ export function attachLaneKey(mode, msg, defaultCwd = "") {
|
|
|
106
107
|
* (直列化に使う内部キーそのものは変えない)。
|
|
107
108
|
*/
|
|
108
109
|
export function laneKindForLog(lane) {
|
|
109
|
-
const m = /^(claude|codex):(cwd|resume|stream):/.exec(typeof lane === "string" ? lane : "")
|
|
110
|
+
const m = /^(claude|codex|grok):(cwd|resume|stream):/.exec(typeof lane === "string" ? lane : "")
|
|
110
111
|
return m ? `${m[1]}:${m[2]}` : lane
|
|
111
112
|
}
|
|
112
113
|
|
|
@@ -115,7 +116,7 @@ export function laneKindForLog(lane) {
|
|
|
115
116
|
* run: (msg: object) => Promise<void>|void,
|
|
116
117
|
* logger?: {debug?: Function, warn?: Function, error?: Function},
|
|
117
118
|
* shouldBypass?: (type: string|undefined) => boolean,
|
|
118
|
-
* defaultCwd?: (mode: "claude"|"codex") => string,
|
|
119
|
+
* defaultCwd?: (mode: "claude"|"codex"|"grok") => string,
|
|
119
120
|
* }} options
|
|
120
121
|
*/
|
|
121
122
|
export class DispatchScheduler {
|
|
@@ -151,7 +152,7 @@ export class DispatchScheduler {
|
|
|
151
152
|
}
|
|
152
153
|
|
|
153
154
|
_rememberStreamLane(streamId, laneKey) {
|
|
154
|
-
const mode =
|
|
155
|
+
const mode = /^(claude|codex|grok):/.exec(laneKey)?.[1] ?? "claude"
|
|
155
156
|
const streamKey = streamId ? `${mode}:${streamId}` : ""
|
|
156
157
|
if (!streamKey) return
|
|
157
158
|
this.streamLaneKeys.set(streamKey, laneKey)
|
|
@@ -177,7 +178,7 @@ export class DispatchScheduler {
|
|
|
177
178
|
* cwd 省略時にハンドラ/bridge が使う既定 cwd を、CLI 種別ごとに引く。
|
|
178
179
|
* ⚠️ レーン分類は全メッセージが通る経路なので、設定の読み取りが失敗しても dispatch を
|
|
179
180
|
* 止めない。取れなければ省略時の従来分類 (stream / control) へ戻す。
|
|
180
|
-
* @param {"claude"|"codex"} mode
|
|
181
|
+
* @param {"claude"|"codex"|"grok"} mode
|
|
181
182
|
*/
|
|
182
183
|
_resolveDefaultCwd(mode) {
|
|
183
184
|
try {
|
|
@@ -201,15 +202,16 @@ export class DispatchScheduler {
|
|
|
201
202
|
if (type === "chat.session.stop") {
|
|
202
203
|
if (msg.scope === "cwd") {
|
|
203
204
|
const cwd = typeof msg.cwd === "string" ? msg.cwd.trim() : ""
|
|
204
|
-
return cwd && (msg.cli_kind === "claude" || msg.cli_kind === "codex")
|
|
205
|
+
return cwd && (msg.cli_kind === "claude" || msg.cli_kind === "codex" || msg.cli_kind === "grok")
|
|
205
206
|
? `${msg.cli_kind}:cwd:${cwd}` : "control"
|
|
206
207
|
}
|
|
207
208
|
if (msg.cli_kind === "claude") {
|
|
208
209
|
const cwd = typeof msg.cwd === "string" ? msg.cwd.trim() : ""
|
|
209
210
|
return cwd ? `claude:cwd:${cwd}` : "control"
|
|
210
211
|
}
|
|
211
|
-
if (msg.cli_kind === "codex" && msg.stream_id) {
|
|
212
|
-
|
|
212
|
+
if ((msg.cli_kind === "codex" || msg.cli_kind === "grok") && msg.stream_id) {
|
|
213
|
+
const kind = msg.cli_kind
|
|
214
|
+
return this.streamLaneKeys.get(`${kind}:${msg.stream_id}`) || `${kind}:stream:${msg.stream_id}`
|
|
213
215
|
}
|
|
214
216
|
return "control"
|
|
215
217
|
}
|
|
@@ -240,7 +242,7 @@ export class DispatchScheduler {
|
|
|
240
242
|
|
|
241
243
|
_previousFor(lane) {
|
|
242
244
|
const own = this.laneTails.get(lane)
|
|
243
|
-
const match = /^(claude|codex):(global|cwd:|resume:|stream:)/.exec(lane)
|
|
245
|
+
const match = /^(claude|codex|grok):(global|cwd:|resume:|stream:)/.exec(lane)
|
|
244
246
|
if (!match) return own || Promise.resolve()
|
|
245
247
|
|
|
246
248
|
const mode = match[1]
|
|
@@ -311,7 +313,7 @@ export class DispatchScheduler {
|
|
|
311
313
|
const streamId = typeof msg?.stream_id === "string" ? msg.stream_id : ""
|
|
312
314
|
const mode = chatMode(type)
|
|
313
315
|
const streamKey = mode && streamId ? `${mode}:${streamId}` : ""
|
|
314
|
-
const isDetach = type === "claude.detach" || type === "codex.detach"
|
|
316
|
+
const isDetach = type === "claude.detach" || type === "codex.detach" || type === "grok.detach"
|
|
315
317
|
if (isDetach && streamKey) {
|
|
316
318
|
// attach がまだ lane 内で待機中でも、その実行前に stale と判定できるよう
|
|
317
319
|
// detach の dispatch 完了を待たず受信時点の順序を記録する。真偽値だけだと
|
|
@@ -332,7 +334,7 @@ export class DispatchScheduler {
|
|
|
332
334
|
return
|
|
333
335
|
}
|
|
334
336
|
if (
|
|
335
|
-
(type === "claude.attach" || type === "codex.attach") &&
|
|
337
|
+
(type === "claude.attach" || type === "codex.attach" || type === "grok.attach") &&
|
|
336
338
|
streamKey &&
|
|
337
339
|
(this.detachedAtByStream.get(streamKey) ?? -1) > sequence
|
|
338
340
|
) {
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { EventEmitter } from 'node:events'
|
|
3
|
+
import { createInterface } from 'node:readline'
|
|
4
|
+
|
|
5
|
+
/** Transport only: one Grok ACP process, newline-delimited JSON-RPC. */
|
|
6
|
+
export class GrokAcpClient extends EventEmitter {
|
|
7
|
+
constructor({ grokCmd, cwd, env, args = ['agent', 'stdio'], requestTimeoutMs = 20_000, logger } = {}) {
|
|
8
|
+
super()
|
|
9
|
+
Object.assign(this, { grokCmd, cwd, env, args, requestTimeoutMs, logger })
|
|
10
|
+
this.child = null
|
|
11
|
+
this.closed = false
|
|
12
|
+
this.pending = new Map()
|
|
13
|
+
this.nextId = 0
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
start() {
|
|
17
|
+
if (this.child || this.closed) return
|
|
18
|
+
if (!this.grokCmd) throw new Error('grokCmd is required')
|
|
19
|
+
this.child = spawn(this.grokCmd, this.args, {
|
|
20
|
+
cwd: this.cwd,
|
|
21
|
+
env: { ...(this.env || process.env), GROK_CLAUDE_MCPS_ENABLED: 'false', GROK_CLAUDE_HOOKS_ENABLED: 'false' },
|
|
22
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
23
|
+
detached: true,
|
|
24
|
+
})
|
|
25
|
+
this.done = new Promise((resolve) => this.child.once('close', resolve))
|
|
26
|
+
createInterface({ input: this.child.stdout }).on('line', (line) => {
|
|
27
|
+
if (this.closed) return
|
|
28
|
+
let message
|
|
29
|
+
try { message = JSON.parse(line) } catch {
|
|
30
|
+
this.logger?.warn?.('grok ACP: invalid JSON line skipped')
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
if (!message || typeof message !== 'object') return
|
|
34
|
+
if (message.method !== undefined) {
|
|
35
|
+
this.emit(message.id != null ? 'request' : 'notification', message)
|
|
36
|
+
} else {
|
|
37
|
+
const pending = this.pending.get(message.id)
|
|
38
|
+
if (message.error) pending?.reject(Object.assign(new Error(message.error.message), { code: message.error.code }))
|
|
39
|
+
else pending?.resolve(message.result)
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
// Do not copy arbitrary stderr (which can contain credentials) into logs.
|
|
43
|
+
this.child.stderr.on('data', (chunk) => this.emit('stderr', chunk))
|
|
44
|
+
this.child.stdin.on('error', (error) => this._fail(error))
|
|
45
|
+
this.child.on('error', (error) => this._fail(error))
|
|
46
|
+
this.child.on('exit', (code, signal) => {
|
|
47
|
+
this.closed = true
|
|
48
|
+
this._rejectAll(new Error(`grok ACP exited (code=${code}, signal=${signal})`))
|
|
49
|
+
void this.stop()
|
|
50
|
+
this.emit('exit', { code, signal })
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
_fail(error) {
|
|
55
|
+
if (this.closed) return
|
|
56
|
+
this._rejectAll(error)
|
|
57
|
+
this.emit('error', error)
|
|
58
|
+
void this.stop()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
_rejectAll(error) {
|
|
62
|
+
for (const pending of this.pending.values()) pending.reject(error)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
_write(message) {
|
|
66
|
+
if (!this.child || this.closed) throw new Error('grok ACP is not running')
|
|
67
|
+
this.child.stdin.write(JSON.stringify({ jsonrpc: '2.0', ...message }) + '\n')
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
request(method, params, { timeoutMs = this.requestTimeoutMs } = {}) {
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
const id = ++this.nextId
|
|
73
|
+
let timer
|
|
74
|
+
const settle = (fn) => (value) => {
|
|
75
|
+
clearTimeout(timer)
|
|
76
|
+
this.pending.delete(id)
|
|
77
|
+
fn(value)
|
|
78
|
+
}
|
|
79
|
+
const pending = { resolve: settle(resolve), reject: settle(reject) }
|
|
80
|
+
this.pending.set(id, pending)
|
|
81
|
+
if (timeoutMs > 0) timer = setTimeout(() => pending.reject(new Error(`grok ACP request timed out: ${method}`)), timeoutMs)
|
|
82
|
+
try { this._write({ id, method, params }) } catch (error) { pending.reject(error) }
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
notify(method, params) { if (this.closed) return; this._write({ method, params }) }
|
|
87
|
+
respond(id, result = {}) { if (this.closed) return; this._write({ id, result }) }
|
|
88
|
+
respondError(id, error) { if (this.closed) return; this._write({ id, error }) }
|
|
89
|
+
|
|
90
|
+
stop() {
|
|
91
|
+
if (this.stopPromise) return this.stopPromise
|
|
92
|
+
this.closed = true
|
|
93
|
+
this._rejectAll(new Error('grok ACP stopped'))
|
|
94
|
+
this.stopPromise = this._reapGroup()
|
|
95
|
+
return this.stopPromise
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Resolves true once the process group is gone, false if it survived SIGKILL (never rejects). */
|
|
99
|
+
async _reapGroup() {
|
|
100
|
+
const pid = this.child?.pid
|
|
101
|
+
if (!Number.isInteger(pid)) return true
|
|
102
|
+
const exists = () => {
|
|
103
|
+
try { process.kill(-pid, 0); return true } catch (error) {
|
|
104
|
+
// EPERM (and any unexpected error) is not evidence of disappearance.
|
|
105
|
+
return error.code !== 'ESRCH'
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const kill = (signal) => {
|
|
109
|
+
try { process.kill(-pid, signal) } catch { /* Polling checks disappearance. */ }
|
|
110
|
+
}
|
|
111
|
+
const waitUntilGone = async (timeoutMs) => {
|
|
112
|
+
const deadline = Date.now() + timeoutMs
|
|
113
|
+
while (exists()) {
|
|
114
|
+
const remaining = deadline - Date.now()
|
|
115
|
+
if (remaining <= 0) return false
|
|
116
|
+
// Keep the host alive until group cleanup completes.
|
|
117
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(50, remaining)))
|
|
118
|
+
}
|
|
119
|
+
return true
|
|
120
|
+
}
|
|
121
|
+
try { this.child.stdin.end() } catch { /* Already closed. */ }
|
|
122
|
+
kill('SIGTERM')
|
|
123
|
+
if (await waitUntilGone(3000)) return true
|
|
124
|
+
kill('SIGKILL')
|
|
125
|
+
if (await waitUntilGone(1000)) return true
|
|
126
|
+
// No retry: signalling the same PGID later risks hitting a reused process group.
|
|
127
|
+
this.logger?.warn?.(`grok ACP: process group ${pid} remains after SIGKILL`)
|
|
128
|
+
return false
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { accessSync, statSync } from "node:fs"
|
|
2
|
+
import { execFileSync } from "node:child_process"
|
|
3
|
+
import os from "node:os"
|
|
4
|
+
import { createBinaryLookup } from "./codex-binary.mjs"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Cockpit が起動する Grok の共通 resolver。
|
|
8
|
+
*
|
|
9
|
+
* 呼び出しごとに再評価し、バイナリの更新・一時不在からの復帰を daemon 再起動なしで
|
|
10
|
+
* 反映する。HUB_GROK_BIN は明示指定なので、不正時に別バイナリへ黙って切り替えない。
|
|
11
|
+
*/
|
|
12
|
+
export function resolveGrokBinary({
|
|
13
|
+
env = process.env,
|
|
14
|
+
homedir = os.homedir(),
|
|
15
|
+
platform = process.platform,
|
|
16
|
+
accessSyncImpl = accessSync,
|
|
17
|
+
statSyncImpl = statSync,
|
|
18
|
+
execFileSyncImpl = execFileSync,
|
|
19
|
+
} = {}) {
|
|
20
|
+
const { pathApi, executable, findOnPath } = createBinaryLookup({
|
|
21
|
+
platform, accessSyncImpl, statSyncImpl, execFileSyncImpl,
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
const explicit = typeof env.HUB_GROK_BIN === "string" ? env.HUB_GROK_BIN.trim() : ""
|
|
25
|
+
if (explicit) {
|
|
26
|
+
if (!pathApi.isAbsolute(explicit)) {
|
|
27
|
+
return { available: false, path: explicit, source: "env", error: `HUB_GROK_BIN must be an absolute path: ${explicit}` }
|
|
28
|
+
}
|
|
29
|
+
const explicitPath = explicit
|
|
30
|
+
if (executable(explicitPath)) {
|
|
31
|
+
return { available: true, path: explicitPath, source: "env" }
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
available: false,
|
|
35
|
+
path: explicitPath,
|
|
36
|
+
source: "env",
|
|
37
|
+
error: `HUB_GROK_BIN is not executable: ${explicitPath}`,
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const fromPath = findOnPath("grok")
|
|
42
|
+
if (fromPath) return fromPath
|
|
43
|
+
|
|
44
|
+
const fallbackPath = pathApi.join(homedir, ".grok", "bin", "grok")
|
|
45
|
+
if (executable(fallbackPath)) {
|
|
46
|
+
return { available: true, path: fallbackPath, source: "home" }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return { available: false, source: "none", error: "grok binary not found" }
|
|
50
|
+
}
|