@cocorograph/hub-agent 0.8.113 → 0.8.115
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/claude-stream-bridge.mjs +7 -0
- package/src/codex-binary.mjs +43 -27
- package/src/config.mjs +9 -4
- package/src/dispatch-scheduler.mjs +13 -11
- package/src/enroll.mjs +5 -0
- 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 +295 -57
- 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.115",
|
|
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 }
|
|
@@ -454,6 +454,7 @@ export class ClaudeStreamSession {
|
|
|
454
454
|
cwd,
|
|
455
455
|
model,
|
|
456
456
|
permissionMode,
|
|
457
|
+
chromeEnabled,
|
|
457
458
|
maxTurns,
|
|
458
459
|
maxThinkingTokens,
|
|
459
460
|
effort,
|
|
@@ -495,6 +496,7 @@ export class ClaudeStreamSession {
|
|
|
495
496
|
this._seq = nextSessionSeq()
|
|
496
497
|
this.cwd = cwd
|
|
497
498
|
this.model = model || null
|
|
499
|
+
this.chromeEnabled = chromeEnabled === true
|
|
498
500
|
this.permissionMode = permissionMode || null
|
|
499
501
|
this.maxTurns = typeof maxTurns === "number" ? maxTurns : null
|
|
500
502
|
this.maxThinkingTokens =
|
|
@@ -1534,6 +1536,7 @@ export class ClaudeStreamSession {
|
|
|
1534
1536
|
abortController: this._abortController,
|
|
1535
1537
|
stderr: (data) => this._stderrHandler(data),
|
|
1536
1538
|
}
|
|
1539
|
+
if (this.chromeEnabled) options.extraArgs = { ...options.extraArgs, chrome: null }
|
|
1537
1540
|
if (this.model) options.model = this.model
|
|
1538
1541
|
if (this.permissionMode) options.permissionMode = this.permissionMode
|
|
1539
1542
|
// Phase B: チャット SDK に効くオプション (ツール往復上限)。
|
|
@@ -2272,6 +2275,7 @@ export class ClaudeStreamSession {
|
|
|
2272
2275
|
abortController: this._residentAbortController,
|
|
2273
2276
|
stderr: (data) => this._stderrHandler(data),
|
|
2274
2277
|
}
|
|
2278
|
+
if (this.chromeEnabled) options.extraArgs = { ...options.extraArgs, chrome: null }
|
|
2275
2279
|
if (this.model) options.model = this.model
|
|
2276
2280
|
if (this.permissionMode) options.permissionMode = this.permissionMode
|
|
2277
2281
|
if (this.maxTurns != null) options.maxTurns = this.maxTurns
|
|
@@ -2985,6 +2989,7 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
2985
2989
|
* cwd?: string,
|
|
2986
2990
|
* model?: string|null,
|
|
2987
2991
|
* permissionMode?: string|null,
|
|
2992
|
+
* chromeEnabled?: boolean,
|
|
2988
2993
|
* maxTurns?: number|null,
|
|
2989
2994
|
* maxThinkingTokens?: number|null,
|
|
2990
2995
|
* effort?: string|null,
|
|
@@ -2997,6 +3002,7 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
2997
3002
|
cwd,
|
|
2998
3003
|
model,
|
|
2999
3004
|
permissionMode,
|
|
3005
|
+
chromeEnabled,
|
|
3000
3006
|
maxTurns,
|
|
3001
3007
|
maxThinkingTokens,
|
|
3002
3008
|
effort,
|
|
@@ -3071,6 +3077,7 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
3071
3077
|
cwd: scwd,
|
|
3072
3078
|
model: model || null,
|
|
3073
3079
|
permissionMode: permissionMode || null,
|
|
3080
|
+
chromeEnabled,
|
|
3074
3081
|
maxTurns: typeof maxTurns === "number" ? maxTurns : null,
|
|
3075
3082
|
maxThinkingTokens:
|
|
3076
3083
|
typeof maxThinkingTokens === "number" ? maxThinkingTokens : null,
|
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
|
) {
|
package/src/enroll.mjs
CHANGED
|
@@ -75,6 +75,11 @@ export async function enroll(enrollmentToken, opts = {}) {
|
|
|
75
75
|
hub_url: hubUrl,
|
|
76
76
|
// Hub から取得した Claude CLI 設定 (cockpit 管理画面でユーザーが指定可能)。
|
|
77
77
|
// backend 未対応時は空文字で保存され、起動時の cmd 組み立てで無視される。
|
|
78
|
+
// Claude in Chrome: boolean で届いたときだけ保存する。古い Hub は値を返さないのでキーを
|
|
79
|
+
// 省略し (= 無効)、既存 agent.json は読み直さない (破損時に --force 再登録を妨げないため)。
|
|
80
|
+
...(typeof data.claude_chrome_enabled === "boolean"
|
|
81
|
+
? { claude_chrome_enabled: data.claude_chrome_enabled }
|
|
82
|
+
: {}),
|
|
78
83
|
claude_model: typeof data.claude_model === "string" ? data.claude_model : "",
|
|
79
84
|
claude_permission_mode:
|
|
80
85
|
typeof data.claude_permission_mode === "string" ? data.claude_permission_mode : "",
|
|
@@ -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
|
+
}
|