@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.8.114",
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 の定義側 2 箇所**(claude-stream-bridge / codex-stream-bridge)に置く。
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 : null
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 }
@@ -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 = platform === "win32" ? path.win32 : path
21
- const executable = (candidate) => {
22
- try {
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
- try {
54
- const lookup = platform === "win32" ? "where" : "which"
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 = laneKey.startsWith("codex:") ? "codex" : "claude"
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
- return this.streamLaneKeys.get("codex:" + msg.stream_id) || `codex:stream:${msg.stream_id}`
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
+ }