@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/src/main.mjs CHANGED
@@ -55,6 +55,8 @@ import { AsyncTtlCache } from "./async-ttl-cache.mjs"
55
55
  import { makePersistentSeenInputIdRegistry } from "./seen-input-id-store.mjs"
56
56
  import { ClaudeStreamBridge } from "./claude-stream-bridge.mjs"
57
57
  import { installClaudeDisplayMask } from "./claude-display-mask.mjs"
58
+ import { GrokStreamBridge } from "./grok-stream-bridge.mjs"
59
+ import { resolveGrokBinary } from "./grok-binary.mjs"
58
60
  import { CodexMcpControl } from "./codex-mcp-control.mjs"
59
61
  import { CodexStreamBridge, classifyCodexInputError } from "./codex-stream-bridge.mjs"
60
62
  import { installCodexDisplayMask } from "./codex-result-mask.mjs"
@@ -166,6 +168,7 @@ const BUNDLE_MANIFEST_PATH =
166
168
  export function resolveClaudeAttachOptions(config, msg = {}) {
167
169
  return {
168
170
  model: msg.model || config?.claude_model || null,
171
+ chromeEnabled: config?.claude_chrome_enabled === true,
169
172
  permissionMode:
170
173
  msg.permission_mode ||
171
174
  config?.claude_permission_mode ||
@@ -279,7 +282,7 @@ export function withDeadline(promise, timeoutMs, onTimeout) {
279
282
  * WS 受信順 = pane 反映順を守るため false (= 直列キューに残す)。
280
283
  */
281
284
  export function isFastPathMessage(type) {
282
- return type === "claude.interrupt" || type === "codex.interrupt"
285
+ return type === "claude.interrupt" || type === "codex.interrupt" || type === "grok.interrupt"
283
286
  }
284
287
 
285
288
  /**
@@ -315,7 +318,9 @@ export function isFastPathMessage(type) {
315
318
  */
316
319
  export function defaultLaneCwd(config, mode) {
317
320
  const configured = expandTilde(config?.claude_cwd || "") || ""
318
- if (configured || mode !== "claude") return configured
321
+ // Grok は handler(handleGrokMessage の attach)もこの関数で補完する。scheduler と同じ値を
322
+ // 使わないと、cwd 省略の attach と HOME 指定の停止が別レーンへ散る。Codex の契約は変えない。
323
+ if (configured || (mode !== "claude" && mode !== "grok")) return configured
319
324
  return process.env.HOME || process.cwd()
320
325
  }
321
326
 
@@ -324,6 +329,7 @@ export function isReadOnlyRpc(type) {
324
329
  type === "claude.history.request" ||
325
330
  type === "claude.sessions.request" ||
326
331
  type === "codex.sessions.request" ||
332
+ type === "grok.sessions.request" ||
327
333
  type === "codex.history.request" ||
328
334
  type === "profile.list" ||
329
335
  type === "codex.profile.list" ||
@@ -579,7 +585,7 @@ export function _resetBundleSyncStateForTest() {
579
585
  /**
580
586
  * ある target(cli_kind)について、要求 version が既に反映済みかを判定する。
581
587
  * - codex: mcp_approval_applied_version(無ければ installed_version)が要求以上
582
- * - claude: installed_version が要求以上
588
+ * - claude: installed_version と、Grok 導入済みなら grok_mcp_applied_version が要求以上
583
589
  * auto_update:false のときは「対象外(=要求は満たされたものとして扱う=同期しない)」を返す。
584
590
  */
585
591
  async function _bundleTargetSatisfied(cliKind, requestedVersion, readStateImpl, readConfigImpl) {
@@ -588,6 +594,10 @@ async function _bundleTargetSatisfied(cliKind, requestedVersion, readStateImpl,
588
594
  if (!requestedVersion) return false
589
595
  const filesVer = st.installed_version || "0.0.0"
590
596
  if (compareBundleVersions(filesVer, requestedVersion) < 0) return false
597
+ if (cliKind === "claude" && existsSync(process.env.GROK_HOME || path.join(os.homedir(), ".grok"))) {
598
+ const grokVer = st.grok_mcp_applied_version || "0.0.0"
599
+ if (compareBundleVersions(grokVer, requestedVersion) < 0) return false
600
+ }
591
601
  if (cliKind === "codex") {
592
602
  const approvalVer = st.mcp_approval_applied_version || "0.0.0"
593
603
  const approved = compareBundleVersions(approvalVer, requestedVersion) >= 0
@@ -707,6 +717,7 @@ export class DeferredRestartCoordinator {
707
717
  constructor({
708
718
  claudeBridge,
709
719
  codexBridge,
720
+ grokBridge,
710
721
  shutdown,
711
722
  logger,
712
723
  settleMs = 500,
@@ -718,6 +729,7 @@ export class DeferredRestartCoordinator {
718
729
  } = {}) {
719
730
  this.claudeBridge = claudeBridge
720
731
  this.codexBridge = codexBridge
732
+ this.grokBridge = grokBridge
721
733
  this.shutdown = shutdown
722
734
  this.logger = logger
723
735
  this.settleMs = settleMs
@@ -745,6 +757,7 @@ export class DeferredRestartCoordinator {
745
757
  return !!(
746
758
  this.claudeBridge?.hasActiveWork?.() ||
747
759
  this.codexBridge?.hasActiveWork?.() ||
760
+ this.grokBridge?.hasActiveWork?.() ||
748
761
  this.workProviders.some((provider) => provider?.hasActiveWork?.())
749
762
  )
750
763
  }
@@ -809,7 +822,45 @@ export class DeferredRestartCoordinator {
809
822
  * `restore:` 購読は browser に存在しないため、ここで捨てないと Hub へ幽霊 stream が流れる。
810
823
  * 起動処理から切り出してあるのは、この配線そのものをテストから通すため。
811
824
  */
812
- export function wireBridgeEvents(client, { claudeBridge, codexBridge }) {
825
+ export function wireBridgeEvents(client, { claudeBridge, codexBridge, grokBridge, logger: bridgeLogger = logger }) {
826
+ if (grokBridge) {
827
+ grokBridge.on("grok_event", ({ stream_id, session_id, seq, update }) => {
828
+ sendBridgeMessage(client, { type: "grok.event", stream_id, thread_id: session_id, seq, event: update })
829
+ })
830
+ grokBridge.on("grok_permission", ({ stream_id, request_id, params }) => {
831
+ sendBridgeMessage(client, { type: "grok.permission.request", stream_id,
832
+ thread_id: grokBridge.getThreadId(stream_id), request_id, params })
833
+ })
834
+ // ターン終了・中断で破棄した承認待ち。browser はこれで該当カードを閉じる。
835
+ grokBridge.on("grok_permission_cancelled", ({ stream_id, session_id, request_ids }) => {
836
+ sendBridgeMessage(client, { type: "grok.permission.cancelled", stream_id,
837
+ ...(typeof session_id === "string" && session_id ? { thread_id: session_id } : {}), request_ids })
838
+ })
839
+ // last_seq: 切断中に捨てた本文 chunk(ws-client)の末尾欠落を、後続 update が無くても browser が
840
+ // 検知できるようにする。受信済み seq より大きければ再 attach して history を取り直す。
841
+ const lastSeqField = (lastSeq) => (Number.isInteger(lastSeq) ? { last_seq: lastSeq } : {})
842
+ // thread_id: backend が同じ会話を開いている他端末へ fan-out するために要る(Codex の event と同じ)。
843
+ const threadField = (sessionId) => (typeof sessionId === "string" && sessionId ? { thread_id: sessionId } : {})
844
+ grokBridge.on("grok_queue_state", ({ stream_id, session_id, queue_state }) => {
845
+ sendBridgeMessage(client, { type: "grok.queue.state", stream_id, ...threadField(session_id), queue_state })
846
+ })
847
+ grokBridge.on("grok_turn_complete", ({ stream_id, session_id, stopReason, lastSeq }) => {
848
+ sendBridgeMessage(client, { type: "grok.turn.complete", stream_id, ...threadField(session_id),
849
+ stop_reason: stopReason, ...lastSeqField(lastSeq) })
850
+ })
851
+ grokBridge.on("grok_exit", ({ stream_id, session_id, reason, code, lastSeq }) => {
852
+ sendBridgeMessage(client, { type: "grok.exit", stream_id, thread_id: session_id, reason,
853
+ ...(code !== undefined ? { code } : {}), ...lastSeqField(lastSeq) })
854
+ })
855
+ grokBridge.on("grok_error", ({ stream_id, session_id, message, lastSeq }) => {
856
+ if (!stream_id) {
857
+ bridgeLogger?.warn?.({ err: message }, "grok bridge error without stream")
858
+ return
859
+ }
860
+ sendBridgeMessage(client, { type: "grok.error", stream_id, ...threadField(session_id), error: message,
861
+ ...lastSeqField(lastSeq) })
862
+ })
863
+ }
813
864
  // stream-json モード (Sprint G): Claude Code を SDK 経由で起動し、stream イベントを
814
865
  // そのまま browser に転送する。SDK 未インストール時は claudeBridge=null で全 attach が
815
866
  // claude.error を返す経路に分岐 (dispatch 側で判定)。
@@ -961,6 +1012,21 @@ export async function applyStartupEnv({ logger, deps = {} }) {
961
1012
  }
962
1013
  }
963
1014
 
1015
+ // ACP のプロセスグループ停止期限とは別に、daemon 終了の待機上限を設ける。
1016
+ export async function shutdownGrokBridge(grokBridge, logger, timeoutMs = 6_000) {
1017
+ if (!grokBridge) return
1018
+ try {
1019
+ const result = await withDeadline(
1020
+ Promise.resolve().then(() => grokBridge.shutdown()),
1021
+ timeoutMs,
1022
+ () => logger?.warn?.({ timeout: true }, "grok shutdown incomplete"),
1023
+ )
1024
+ if (result?.unreaped > 0) logger?.warn?.(result, "grok shutdown incomplete")
1025
+ } catch (err) {
1026
+ logger?.warn?.({ err: err?.message }, "grok shutdown failed")
1027
+ }
1028
+ }
1029
+
964
1030
  export async function startDaemon({ version, claudeSdk } = {}) {
965
1031
  const logRotationTimer = startLogRotation(logger, { isDeferred: hasInheritedOutputChild })
966
1032
  startEventLoopMetrics(logger)
@@ -1022,6 +1088,10 @@ export async function startDaemon({ version, claudeSdk } = {}) {
1022
1088
  // codex app-server ベースの chat ブリッジ。resolver は各 spawn 時に再評価するため、
1023
1089
  // daemon 起動時に一時的に unavailable でも bridge 自体は保持する。
1024
1090
  const codexBridge = new CodexStreamBridge({ logger })
1091
+ const grokBridge = availableClis.includes("grok")
1092
+ ? new GrokStreamBridge({ grokCmd: resolveGrokBinary().path, logger })
1093
+ : null
1094
+ ctx.grokBridge = grokBridge
1025
1095
 
1026
1096
  // Codex の MCP 状態 control (agent-global, セッション非依存)。MCP は ~/.codex/config.toml
1027
1097
  // = アカウント単位の設定で全セッション共有のため、claudeBridge の「MCP control 専用
@@ -1094,7 +1164,7 @@ export async function startDaemon({ version, claudeSdk } = {}) {
1094
1164
  logger.warn({ err: err.message }, "ws client error (will retry via close handler)")
1095
1165
  })
1096
1166
 
1097
- wireBridgeEvents(client, { claudeBridge, codexBridge })
1167
+ wireBridgeEvents(client, { claudeBridge, codexBridge, grokBridge, logger })
1098
1168
 
1099
1169
  // Hub からのメッセージ dispatch は、接続世代 + 機能レーン単位で直列実行する。
1100
1170
  //
@@ -1115,6 +1185,7 @@ export async function startDaemon({ version, claudeSdk } = {}) {
1115
1185
  client,
1116
1186
  claudeBridge,
1117
1187
  codexBridge,
1188
+ grokBridge,
1118
1189
  codexMcpControl,
1119
1190
  uploadManager,
1120
1191
  }
@@ -1147,7 +1218,7 @@ export async function startDaemon({ version, claudeSdk } = {}) {
1147
1218
  }, 60_000)
1148
1219
 
1149
1220
  // 会話の回収と常駐数制限を定期実行する。
1150
- const stateLoop = startStateLoop({ client, logger, intervalMs: 5_000, claudeBridge, codexBridge })
1221
+ const stateLoop = startStateLoop({ client, logger, intervalMs: 5_000, claudeBridge, codexBridge, grokBridge })
1151
1222
  // bundle hook (cockpit_session_event_hook.py) が /tmp/cockpit_session_events/<name>.json
1152
1223
  // に書き出す UserPromptSubmit / Stop / ready の event を fs.watch で拾って WS push する。
1153
1224
  // text マーカー判定 (detectStatusFromText) より精度が高い「ターン境界」の signal。
@@ -1163,9 +1234,10 @@ export async function startDaemon({ version, claudeSdk } = {}) {
1163
1234
  const siblingInjectBridge = new SiblingInjectBridge({
1164
1235
  claudeBridge,
1165
1236
  codexBridge,
1237
+ grokBridge,
1166
1238
  logger,
1167
1239
  starter: ({ cwd, cliKind }) => ensureChatSession({
1168
- cwd, cliKind, bridges: { claudeBridge, codexBridge }, logger,
1240
+ cwd, cliKind, bridges: { claudeBridge, codexBridge, grokBridge }, logger,
1169
1241
  resolveClaudeOptions: () => resolveClaudeAttachOptions(ctx.config),
1170
1242
  }),
1171
1243
  onRestartRequested: () => restartCoordinator.request(),
@@ -1208,6 +1280,7 @@ export async function startDaemon({ version, claudeSdk } = {}) {
1208
1280
  restartCoordinator?.stop?.()
1209
1281
  claudeBridge?.shutdown?.()
1210
1282
  codexBridge?.shutdown?.()
1283
+ await shutdownGrokBridge(grokBridge, logger)
1211
1284
  // 受理済み input_id の保存を待ってから exit する(再起動後の再送で二重実行しないため)。
1212
1285
  const persistence = await waitForClaudeInputPersistence()
1213
1286
  if (persistence === "timeout") {
@@ -1222,6 +1295,7 @@ export async function startDaemon({ version, claudeSdk } = {}) {
1222
1295
  restartCoordinator = new DeferredRestartCoordinator({
1223
1296
  claudeBridge,
1224
1297
  codexBridge,
1298
+ grokBridge,
1225
1299
  shutdown,
1226
1300
  logger,
1227
1301
  lifecycle,
@@ -1250,7 +1324,7 @@ export async function startDaemon({ version, claudeSdk } = {}) {
1250
1324
  )
1251
1325
  })
1252
1326
 
1253
- return { client, plugins, claudeBridge, codexBridge, codexMcpControl, uploadManager }
1327
+ return { client, plugins, claudeBridge, codexBridge, grokBridge, codexMcpControl, uploadManager }
1254
1328
  }
1255
1329
 
1256
1330
  const SESSION_EVENTS_DIR =
@@ -2159,7 +2233,7 @@ export function decideTurnActiveSource({
2159
2233
  return "none"
2160
2234
  }
2161
2235
 
2162
- export function startStateLoop({ client, logger, intervalMs, claudeBridge, codexBridge }) {
2236
+ export function startStateLoop({ client, logger, intervalMs, claudeBridge, codexBridge, grokBridge }) {
2163
2237
  // 総 live 上限 M のログ抑制状態。⚠️ **この state loop インスタンスが所有する**(module-global に
2164
2238
  // 置くと、複数インスタンスや並行テストが互いのログを抑制してしまう)。「超過しているが全部
2165
2239
  // 保護されていて刈れない」tick は 5 秒ごとに同じ行を出し続けるので、状態が変わったときと
@@ -2202,12 +2276,17 @@ export function startStateLoop({ client, logger, intervalMs, claudeBridge, codex
2202
2276
  } catch (err) {
2203
2277
  logger?.warn({ err: err?.message }, "gcAbandonedSessions failed")
2204
2278
  }
2279
+ try {
2280
+ grokBridge?.gcAbandonedSessions?.()
2281
+ } catch (err) {
2282
+ logger?.warn?.({ err: err?.message }, "grok gcAbandonedSessions failed")
2283
+ }
2205
2284
  // 総 live 上限 M(claude + codex 合算、既定 20)。per-cwd を 1 → K(既定 3)へ緩めたぶん、
2206
2285
  // cwd 単位の境界だけでは総量が膨らむため、cwd をまたいだ天井をここで適用する。
2207
2286
  // ⚠️ 両 CLI の reconcile が **終わった後**に呼ぶ(cwd 単位で畳んだ結果の生存数で判定する)。
2208
2287
  try {
2209
2288
  enforceTotalLiveBound({
2210
- bridges: [claudeBridge, codexBridge].filter(Boolean),
2289
+ bridges: [claudeBridge, codexBridge, grokBridge].filter(Boolean),
2211
2290
  logger,
2212
2291
  logState: warmBoundsLogState,
2213
2292
  })
@@ -2324,13 +2403,17 @@ export function startStateLoop({ client, logger, intervalMs, claudeBridge, codex
2324
2403
  */
2325
2404
  export async function maybeSyncClaudeSettings(msg, ctx) {
2326
2405
  const hasClaudeFields =
2327
- typeof msg?.claude_model === "string" || typeof msg?.claude_permission_mode === "string"
2406
+ typeof msg?.claude_model === "string" || typeof msg?.claude_permission_mode === "string" ||
2407
+ typeof msg?.claude_chrome_enabled === "boolean"
2328
2408
  const hasCodexFields =
2329
2409
  typeof msg?.codex_model === "string" || typeof msg?.codex_effort === "string"
2330
2410
  if (!hasClaudeFields && !hasCodexFields) return
2331
2411
 
2332
2412
  const nextModel = typeof msg.claude_model === "string" ? msg.claude_model : ctx.config.claude_model || ""
2333
2413
  const nextMode = typeof msg.claude_permission_mode === "string" ? msg.claude_permission_mode : ctx.config.claude_permission_mode || ""
2414
+ const nextChromeEnabled = typeof msg.claude_chrome_enabled === "boolean"
2415
+ ? msg.claude_chrome_enabled
2416
+ : ctx.config.claude_chrome_enabled
2334
2417
  const nextCodexModel = typeof msg.codex_model === "string" ? msg.codex_model : ctx.config.codex_model || ""
2335
2418
  const nextCodexEffort = typeof msg.codex_effort === "string" ? msg.codex_effort : ctx.config.codex_effort || ""
2336
2419
  const prevModel = ctx.config.claude_model || ""
@@ -2340,6 +2423,7 @@ export async function maybeSyncClaudeSettings(msg, ctx) {
2340
2423
  if (
2341
2424
  nextModel === prevModel &&
2342
2425
  nextMode === prevMode &&
2426
+ nextChromeEnabled === ctx.config.claude_chrome_enabled &&
2343
2427
  nextCodexModel === prevCodexModel &&
2344
2428
  nextCodexEffort === prevCodexEffort
2345
2429
  ) {
@@ -2347,6 +2431,7 @@ export async function maybeSyncClaudeSettings(msg, ctx) {
2347
2431
  }
2348
2432
  ctx.config.claude_model = nextModel
2349
2433
  ctx.config.claude_permission_mode = nextMode
2434
+ if (typeof nextChromeEnabled === "boolean") ctx.config.claude_chrome_enabled = nextChromeEnabled
2350
2435
  ctx.config.codex_model = nextCodexModel
2351
2436
  ctx.config.codex_effort = nextCodexEffort
2352
2437
  try {
@@ -2356,6 +2441,7 @@ export async function maybeSyncClaudeSettings(msg, ctx) {
2356
2441
  hub_url: ctx.config.hub_url,
2357
2442
  claude_model: nextModel,
2358
2443
  claude_permission_mode: nextMode,
2444
+ claude_chrome_enabled: nextChromeEnabled,
2359
2445
  codex_model: nextCodexModel,
2360
2446
  codex_effort: nextCodexEffort,
2361
2447
  })
@@ -2394,7 +2480,7 @@ export function handleStreamsSyncResponse(msg, ctx) {
2394
2480
  const activeIds = new Set(
2395
2481
  Array.isArray(msg?.active_stream_ids) ? msg.active_stream_ids : [],
2396
2482
  )
2397
- const activeByMode = { claude: new Set(), codex: new Set() }
2483
+ const activeByMode = { claude: new Set(), codex: new Set(), grok: new Set() }
2398
2484
  const classifiedIds = new Set()
2399
2485
  if (Array.isArray(msg?.active_streams)) {
2400
2486
  for (const item of msg.active_streams) {
@@ -2432,6 +2518,14 @@ export function handleStreamsSyncResponse(msg, ctx) {
2432
2518
  ctx.logger,
2433
2519
  )
2434
2520
  }
2521
+ if (activeByMode.grok.size > 0) {
2522
+ pruneInactiveChatSubscribers(
2523
+ ctx.grokBridge,
2524
+ activeByMode.grok,
2525
+ "grok",
2526
+ ctx.logger,
2527
+ )
2528
+ }
2435
2529
  }
2436
2530
  }
2437
2531
 
@@ -2487,6 +2581,7 @@ export function resetSeenClaudeChatInputIdsForTest() {
2487
2581
  seenClaudeChatInputIds = makePersistentSeenInputIdRegistry(SEEN_INPUT_IDS_MAX)
2488
2582
  }
2489
2583
  const seenCodexChatInputIds = makeSeenInputIdRegistry()
2584
+ const seenGrokChatInputIds = makeSeenInputIdRegistry()
2490
2585
 
2491
2586
  /**
2492
2587
  * 通常終了(SIGTERM・安全な再起動)の直前に、処理中の claude.input と受理済み ID の保存を待つ。
@@ -2880,6 +2975,126 @@ export function buildAttachHistoryPromise({
2880
2975
  }))
2881
2976
  }
2882
2977
 
2978
+ /**
2979
+ * 要求の失敗を、bridge が通知するターンの失敗(phase なし)と区別する印。
2980
+ * attach は接続そのものの失敗。それ以外の要求の失敗はターンを終わらせない(browser はターン終了に使わない)。
2981
+ */
2982
+ function grokRequestPhase(type) {
2983
+ return type === "grok.attach" ? { phase: "attach" } : { phase: "request", request_type: type }
2984
+ }
2985
+
2986
+ export async function handleGrokMessage(msg, ctx) {
2987
+ const bridge = ctx.grokBridge
2988
+ const stream_id = msg.stream_id
2989
+ const send = (payload) => ctx.client.send({ ...payload, stream_id })
2990
+ if (msg.type === "grok.queue.cancel") {
2991
+ const response = { type: "grok.queue.response", request_id: msg.request_id || null, input_id: msg.id || null }
2992
+ let canceled
2993
+ try {
2994
+ canceled = bridge?.cancelQueued({ stream_id, id: msg.id })
2995
+ } catch (err) {
2996
+ ctx.logger?.warn?.(
2997
+ { err: err?.message, stream_id, input_id: msg.id },
2998
+ "grok queued input cancel failed",
2999
+ )
3000
+ send({ ...response, error: err?.message || "cancel_failed" })
3001
+ return
3002
+ }
3003
+ send({ ...response, outcome: canceled?.outcome || "stream_missing" })
3004
+ canceled?.flush?.()
3005
+ return
3006
+ }
3007
+ if (msg.type === "grok.sessions.request") {
3008
+ const cwd = msg.cwd || ""
3009
+ const epoch = typeof msg.epoch === "number" ? msg.epoch : undefined
3010
+ const response = { type: "grok.sessions.response", cwd, epoch, sessions: [], session_operations: false }
3011
+ try {
3012
+ if (!bridge) throw new Error("grok_unavailable")
3013
+ const rows = await bridge.listThreads({ cwd, limit: typeof msg.limit === "number" ? msg.limit : undefined })
3014
+ const liveByThread = new Map(bridge.describeLiveSessions(cwd).map((s) => [s.thread_id, s]))
3015
+ response.sessions = rows.filter((row) => typeof row?.sessionId === "string").map((row) => {
3016
+ const updated = Date.parse(row.updatedAt)
3017
+ const live = liveByThread.get(row.sessionId)
3018
+ return { thread_id: row.sessionId, cwd: row.cwd ?? cwd,
3019
+ preview: typeof row.title === "string" ? row.title : "",
3020
+ updated_at_ms: Number.isFinite(updated) ? updated : null,
3021
+ live: !!live, busy: !!live?.busy }
3022
+ })
3023
+ } catch (err) {
3024
+ response.error = err?.message || String(err)
3025
+ }
3026
+ send(response)
3027
+ return
3028
+ }
3029
+ if (!bridge) {
3030
+ // Codex と同じく、input_id 付きの入力には NACK を返す(返さないと browser の再送が止まらない)。
3031
+ const input_id = msg.type === "grok.input" && typeof msg.input_id === "string" && msg.input_id ? msg.input_id : null
3032
+ if (input_id) send({ type: "grok.input.nack", input_id, reason: "stream_missing" })
3033
+ else send({ type: "grok.error", error: "grok_unavailable", ...grokRequestPhase(msg.type) })
3034
+ return
3035
+ }
3036
+ if (msg.type === "grok.input") {
3037
+ const input_id = typeof msg.input_id === "string" && msg.input_id ? msg.input_id : null
3038
+ if (input_id && seenGrokChatInputIds.has(input_id)) {
3039
+ send({ type: "grok.input.ack", input_id })
3040
+ return
3041
+ }
3042
+ try {
3043
+ const rejection = inputLifecycleRejection(ctx)
3044
+ if (rejection) throw new Error(rejection.reason)
3045
+ // 受付は同期で完了するため、ターン処理中の再送も上の受理済み ID で除外する。
3046
+ // ターンの失敗は bridge が監視し、grok_error で通知する。
3047
+ bridge.input({ stream_id, message: msg.message, inputId: input_id || undefined,
3048
+ model: msg.model, effort: msg.effort })
3049
+ if (input_id) {
3050
+ seenGrokChatInputIds.remember(input_id)
3051
+ send({ type: "grok.input.ack", input_id })
3052
+ }
3053
+ } catch (err) {
3054
+ if (input_id) send({ type: "grok.input.nack", input_id, reason: err?.message || String(err) })
3055
+ else send({ type: "grok.error", error: err?.message || String(err), ...grokRequestPhase(msg.type) })
3056
+ }
3057
+ return
3058
+ }
3059
+ if (msg.type === "grok.interrupt") {
3060
+ try {
3061
+ await bridge.interrupt({ stream_id })
3062
+ send({ type: "grok.interrupt.response", ok: true })
3063
+ } catch (err) {
3064
+ send({ type: "grok.interrupt.response", ok: false, error: err?.message || String(err) })
3065
+ }
3066
+ return
3067
+ }
3068
+ try {
3069
+ switch (msg.type) {
3070
+ case "grok.attach": {
3071
+ // claude_cwd → HOME の補完。scheduler のレーン分類と同じ defaultLaneCwd を使う。
3072
+ const cwd = msg.cwd || defaultLaneCwd(ctx.config, "grok")
3073
+ const info = await bridge.attach({ stream_id, cwd, model: msg.model, effort: msg.effort,
3074
+ resumeSessionId: msg.resume_session_id, attachId: msg.attach_id })
3075
+ send({ type: "grok.ready", thread_id: info.sessionId, resuming: info.resuming,
3076
+ warm: info.warm, history: info.history, history_truncated: info.historyTruncated,
3077
+ last_seq: info.lastSeq, turn_active: info.turnActive,
3078
+ queue_state: info.queueState, supports_queue_cancel_response: true })
3079
+ break
3080
+ }
3081
+ case "grok.permission.reply":
3082
+ await bridge.permissionReply({ stream_id, request_id: msg.request_id, result: msg.result })
3083
+ break
3084
+ case "grok.detach":
3085
+ await bridge.detach({ stream_id })
3086
+ break
3087
+ case "grok.keepalive":
3088
+ await bridge.keepalive({ stream_id })
3089
+ break
3090
+ }
3091
+ } catch (err) {
3092
+ // phase: "attach" は接続そのものの失敗(backend はこの stream の登録を外す)。それ以外の要求の
3093
+ // 失敗とターン失敗(bridge の grok_error)は会話が続くので、登録を外させない。
3094
+ send({ type: "grok.error", error: err?.message || String(err), ...grokRequestPhase(msg.type) })
3095
+ }
3096
+ }
3097
+
2883
3098
  /**
2884
3099
  * SDK チャット配達保証 (S2, 2026-07-05): `codex.input` を処理し、browser が付与した
2885
3100
  * `input_id` があれば `codex.input.ack` / `codex.input.nack` を返す (claude.input と
@@ -3429,9 +3644,11 @@ export async function stopChatSessionsUnder(dir, ctx, reason = "worktree-remove"
3429
3644
  const live = describeLiveSessionsUnion({
3430
3645
  claudeBridge: ctx.claudeBridge,
3431
3646
  codexBridge: ctx.codexBridge,
3647
+ grokBridge: ctx.grokBridge,
3432
3648
  })
3433
3649
  const claudeCwds = new Set()
3434
3650
  const codexStreams = new Set()
3651
+ const grokStreams = new Set()
3435
3652
  let stopped = 0
3436
3653
  // ⚠️ 例外を捕まえない。投げれば removeWorktree がその場で中断し、dir は残る (= 削除しない)。
3437
3654
  // 「失敗した分を集めて最後にまとめて投げる」形も書けるが、削除しないのであれば残りを止める
@@ -3452,6 +3669,10 @@ export async function stopChatSessionsUnder(dir, ctx, reason = "worktree-remove"
3452
3669
  stream_id: session.stream_id,
3453
3670
  reason,
3454
3671
  })
3672
+ } else if (session.kind === "grok") {
3673
+ if (grokStreams.has(session.stream_id)) continue
3674
+ grokStreams.add(session.stream_id)
3675
+ stopped += await ctx.grokBridge.stopSessionForStream({ stream_id: session.stream_id, reason })
3455
3676
  }
3456
3677
  }
3457
3678
  return stopped
@@ -4006,6 +4227,16 @@ export async function dispatch(msg, ctx) {
4006
4227
  // async (claude 版は SDK 呼び出しが同期/fire-and-forget)。失敗時は codex.error で
4007
4228
  // browser に伝える。permission.reply の result は method ごとに shape が異なる
4008
4229
  // (accept/decline 等) ため、正規化せず browser が組み立てたものをそのまま渡す。
4230
+ case "grok.attach":
4231
+ case "grok.input":
4232
+ case "grok.queue.cancel":
4233
+ case "grok.interrupt":
4234
+ case "grok.permission.reply":
4235
+ case "grok.detach":
4236
+ case "grok.keepalive":
4237
+ case "grok.sessions.request":
4238
+ await handleGrokMessage(msg, ctx)
4239
+ return
4009
4240
  case "codex.attach": {
4010
4241
  const stream_id = msg.stream_id
4011
4242
  if (!ctx.codexBridge || !codexAvailableForDispatch(ctx)) {
@@ -4086,55 +4317,57 @@ export async function dispatch(msg, ctx) {
4086
4317
  }
4087
4318
  return
4088
4319
  }
4320
+ case "grok.upload":
4089
4321
  case "codex.upload": {
4090
- // B4 (T05699 残タスク, 2026-07-04): Cockpit Codexチャットの添付ファイル受信
4091
- // (チャンク1件)。claude.upload と同じ UploadManager を再利用する (cwd ベースで
4092
- // CLI 非依存の実装のため無改修で流用可能)。全チャンク到着で cwd 配下の
4093
- // .cockpit-uploads/<日付>/ に保存し、保存パスを browser へ返す。browser は
4094
- // そのパスを codex.input 本文に載せ、Codex がシェル/ファイルツールで読む
4095
- // (Claude 版と同じ「パス参照方式」、Codex 側の model/effort 分離と同様に
4096
- // アカウント/プロトコルが違うだけでファイル保存の仕組みは共有する)。
4097
- if (!ctx.uploadManager) {
4098
- ctx.client.send({
4099
- type: "codex.upload.done",
4100
- stream_id: msg.stream_id,
4101
- upload_id: msg.upload_id,
4102
- ok: false,
4103
- error: "upload_unavailable",
4104
- })
4105
- return
4106
- }
4107
- try {
4108
- const res = await ctx.uploadManager.addChunk({
4109
- upload_id: msg.upload_id,
4110
- filename: msg.filename,
4111
- media_type: msg.media_type,
4112
- total_size: msg.total_size,
4113
- total_chunks: msg.total_chunks,
4114
- chunk_index: msg.chunk_index,
4115
- data: msg.data,
4116
- cwd: msg.cwd || ctx.config?.claude_cwd || undefined,
4117
- })
4118
- if (res.done) {
4322
+ // パス参照方式の添付受信を共有し、返信型と既定 cwd だけを切り替える。
4323
+ async function handleUpload(doneType, getDefaultCwd) {
4324
+ if (!ctx.uploadManager) {
4119
4325
  ctx.client.send({
4120
- type: "codex.upload.done",
4326
+ type: doneType,
4121
4327
  stream_id: msg.stream_id,
4122
4328
  upload_id: msg.upload_id,
4123
- ok: true,
4124
- path: res.path,
4125
- filename: res.filename,
4126
- media_type: res.media_type,
4127
- size: res.size,
4329
+ ok: false,
4330
+ error: "upload_unavailable",
4331
+ })
4332
+ return
4333
+ }
4334
+ try {
4335
+ const res = await ctx.uploadManager.addChunk({
4336
+ upload_id: msg.upload_id,
4337
+ filename: msg.filename,
4338
+ media_type: msg.media_type,
4339
+ total_size: msg.total_size,
4340
+ total_chunks: msg.total_chunks,
4341
+ chunk_index: msg.chunk_index,
4342
+ data: msg.data,
4343
+ cwd: msg.cwd || getDefaultCwd(),
4344
+ })
4345
+ if (res.done) {
4346
+ ctx.client.send({
4347
+ type: doneType,
4348
+ stream_id: msg.stream_id,
4349
+ upload_id: msg.upload_id,
4350
+ ok: true,
4351
+ path: res.path,
4352
+ filename: res.filename,
4353
+ media_type: res.media_type,
4354
+ size: res.size,
4355
+ })
4356
+ }
4357
+ } catch (err) {
4358
+ ctx.client.send({
4359
+ type: doneType,
4360
+ stream_id: msg.stream_id,
4361
+ upload_id: msg.upload_id,
4362
+ ok: false,
4363
+ error: err?.message || String(err),
4128
4364
  })
4129
4365
  }
4130
- } catch (err) {
4131
- ctx.client.send({
4132
- type: "codex.upload.done",
4133
- stream_id: msg.stream_id,
4134
- upload_id: msg.upload_id,
4135
- ok: false,
4136
- error: err?.message || String(err),
4137
- })
4366
+ }
4367
+ if (msg.type === "grok.upload") {
4368
+ await handleUpload("grok.upload.done", () => defaultLaneCwd(ctx.config, "grok"))
4369
+ } else {
4370
+ await handleUpload("codex.upload.done", () => ctx.config?.claude_cwd || undefined)
4138
4371
  }
4139
4372
  return
4140
4373
  }
@@ -4771,11 +5004,13 @@ export async function dispatch(msg, ctx) {
4771
5004
  try {
4772
5005
  if (msg.scope === "cwd") {
4773
5006
  const cwd = typeof msg.cwd === "string" ? msg.cwd : ""
4774
- if (msg.cli_kind !== "claude" && msg.cli_kind !== "codex") result.error = "unsupported cli_kind"
5007
+ if (msg.cli_kind !== "claude" && msg.cli_kind !== "codex" && msg.cli_kind !== "grok") result.error = "unsupported cli_kind"
4775
5008
  else if (!cwd || !path.isAbsolute(cwd)) result.error = "cwd required"
4776
5009
  else if (msg.cli_kind === "claude") {
4777
5010
  noteRestoreStop(ctx.sessionRestoreState, "claude", cwd)
4778
5011
  result.stopped = await ctx.claudeBridge.stopSessionsFor({ cwd, reason: "chat-stop" })
5012
+ } else if (msg.cli_kind === "grok") {
5013
+ result.stopped = await ctx.grokBridge?.stopSessionsForCwd({ cwd, reason: "chat-stop" }) ?? 0
4779
5014
  } else {
4780
5015
  noteRestoreStop(ctx.sessionRestoreState, "codex", cwd)
4781
5016
  result.stopped = await ctx.codexBridge.stopSessionsForCwd({ cwd, reason: "chat-stop" })
@@ -4793,6 +5028,9 @@ export async function dispatch(msg, ctx) {
4793
5028
  noteRestoreStop(ctx.sessionRestoreState, "claude", cwd)
4794
5029
  result.stopped = await ctx.claudeBridge.stopSessionForDeletion({ cwd, session_id, reason: "chat-stop" })
4795
5030
  }
5031
+ } else if (msg.cli_kind === "grok") {
5032
+ if (typeof msg.stream_id !== "string" || !msg.stream_id) result.error = "stream_id required"
5033
+ else result.stopped = await ctx.grokBridge?.stopSessionForStream({ stream_id: msg.stream_id, reason: "chat-stop" }) ?? 0
4796
5034
  } else if (msg.cli_kind === "codex") {
4797
5035
  if (typeof msg.stream_id !== "string" || !msg.stream_id) result.error = "stream_id required"
4798
5036
  else {
@@ -4814,7 +5052,7 @@ export async function dispatch(msg, ctx) {
4814
5052
  case "chat.sessions.request": {
4815
5053
  try {
4816
5054
  const snapshot = await buildChatSessionsSnapshot({
4817
- bridges: { claudeBridge: ctx.claudeBridge, codexBridge: ctx.codexBridge },
5055
+ bridges: { claudeBridge: ctx.claudeBridge, codexBridge: ctx.codexBridge, grokBridge: ctx.grokBridge },
4818
5056
  logger: ctx.logger,
4819
5057
  })
4820
5058
  ctx.client.send({ type: "chat.sessions", request_id: msg.request_id, ...withSortAffectingAt(refreshChatSessionsSignals(snapshot, ctx.logger)) })
@@ -17,7 +17,7 @@ export function validReviewEvent(event) {
17
17
  if (event.type !== "system" || event.subtype !== "cross_review" || event.schema_version !== 1) return false
18
18
  if (typeof event.review_id !== "string" || !/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(event.review_id)) return false
19
19
  if (!["started", "completed", "failed", "stopped"].includes(event.phase)) return false
20
- if (!["claude", "codex"].includes(event.reviewer)) return false
20
+ if (!["claude", "codex", "grok"].includes(event.reviewer)) return false
21
21
  if (!Number.isSafeInteger(event.round) || event.round <= 0) return false
22
22
  if (typeof event.workdir !== "string" || !path.isAbsolute(event.workdir)) return false
23
23
  if (typeof event.timestamp !== "string" || !/^\d{4}-\d\d-\d\dT/.test(event.timestamp) || !Number.isFinite(Date.parse(event.timestamp))) return false