@cocorograph/hub-agent 0.8.114 → 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"
@@ -280,7 +282,7 @@ export function withDeadline(promise, timeoutMs, onTimeout) {
280
282
  * WS 受信順 = pane 反映順を守るため false (= 直列キューに残す)。
281
283
  */
282
284
  export function isFastPathMessage(type) {
283
- return type === "claude.interrupt" || type === "codex.interrupt"
285
+ return type === "claude.interrupt" || type === "codex.interrupt" || type === "grok.interrupt"
284
286
  }
285
287
 
286
288
  /**
@@ -316,7 +318,9 @@ export function isFastPathMessage(type) {
316
318
  */
317
319
  export function defaultLaneCwd(config, mode) {
318
320
  const configured = expandTilde(config?.claude_cwd || "") || ""
319
- 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
320
324
  return process.env.HOME || process.cwd()
321
325
  }
322
326
 
@@ -325,6 +329,7 @@ export function isReadOnlyRpc(type) {
325
329
  type === "claude.history.request" ||
326
330
  type === "claude.sessions.request" ||
327
331
  type === "codex.sessions.request" ||
332
+ type === "grok.sessions.request" ||
328
333
  type === "codex.history.request" ||
329
334
  type === "profile.list" ||
330
335
  type === "codex.profile.list" ||
@@ -580,7 +585,7 @@ export function _resetBundleSyncStateForTest() {
580
585
  /**
581
586
  * ある target(cli_kind)について、要求 version が既に反映済みかを判定する。
582
587
  * - codex: mcp_approval_applied_version(無ければ installed_version)が要求以上
583
- * - claude: installed_version が要求以上
588
+ * - claude: installed_version と、Grok 導入済みなら grok_mcp_applied_version が要求以上
584
589
  * auto_update:false のときは「対象外(=要求は満たされたものとして扱う=同期しない)」を返す。
585
590
  */
586
591
  async function _bundleTargetSatisfied(cliKind, requestedVersion, readStateImpl, readConfigImpl) {
@@ -589,6 +594,10 @@ async function _bundleTargetSatisfied(cliKind, requestedVersion, readStateImpl,
589
594
  if (!requestedVersion) return false
590
595
  const filesVer = st.installed_version || "0.0.0"
591
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
+ }
592
601
  if (cliKind === "codex") {
593
602
  const approvalVer = st.mcp_approval_applied_version || "0.0.0"
594
603
  const approved = compareBundleVersions(approvalVer, requestedVersion) >= 0
@@ -708,6 +717,7 @@ export class DeferredRestartCoordinator {
708
717
  constructor({
709
718
  claudeBridge,
710
719
  codexBridge,
720
+ grokBridge,
711
721
  shutdown,
712
722
  logger,
713
723
  settleMs = 500,
@@ -719,6 +729,7 @@ export class DeferredRestartCoordinator {
719
729
  } = {}) {
720
730
  this.claudeBridge = claudeBridge
721
731
  this.codexBridge = codexBridge
732
+ this.grokBridge = grokBridge
722
733
  this.shutdown = shutdown
723
734
  this.logger = logger
724
735
  this.settleMs = settleMs
@@ -746,6 +757,7 @@ export class DeferredRestartCoordinator {
746
757
  return !!(
747
758
  this.claudeBridge?.hasActiveWork?.() ||
748
759
  this.codexBridge?.hasActiveWork?.() ||
760
+ this.grokBridge?.hasActiveWork?.() ||
749
761
  this.workProviders.some((provider) => provider?.hasActiveWork?.())
750
762
  )
751
763
  }
@@ -810,7 +822,45 @@ export class DeferredRestartCoordinator {
810
822
  * `restore:` 購読は browser に存在しないため、ここで捨てないと Hub へ幽霊 stream が流れる。
811
823
  * 起動処理から切り出してあるのは、この配線そのものをテストから通すため。
812
824
  */
813
- 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
+ }
814
864
  // stream-json モード (Sprint G): Claude Code を SDK 経由で起動し、stream イベントを
815
865
  // そのまま browser に転送する。SDK 未インストール時は claudeBridge=null で全 attach が
816
866
  // claude.error を返す経路に分岐 (dispatch 側で判定)。
@@ -962,6 +1012,21 @@ export async function applyStartupEnv({ logger, deps = {} }) {
962
1012
  }
963
1013
  }
964
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
+
965
1030
  export async function startDaemon({ version, claudeSdk } = {}) {
966
1031
  const logRotationTimer = startLogRotation(logger, { isDeferred: hasInheritedOutputChild })
967
1032
  startEventLoopMetrics(logger)
@@ -1023,6 +1088,10 @@ export async function startDaemon({ version, claudeSdk } = {}) {
1023
1088
  // codex app-server ベースの chat ブリッジ。resolver は各 spawn 時に再評価するため、
1024
1089
  // daemon 起動時に一時的に unavailable でも bridge 自体は保持する。
1025
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
1026
1095
 
1027
1096
  // Codex の MCP 状態 control (agent-global, セッション非依存)。MCP は ~/.codex/config.toml
1028
1097
  // = アカウント単位の設定で全セッション共有のため、claudeBridge の「MCP control 専用
@@ -1095,7 +1164,7 @@ export async function startDaemon({ version, claudeSdk } = {}) {
1095
1164
  logger.warn({ err: err.message }, "ws client error (will retry via close handler)")
1096
1165
  })
1097
1166
 
1098
- wireBridgeEvents(client, { claudeBridge, codexBridge })
1167
+ wireBridgeEvents(client, { claudeBridge, codexBridge, grokBridge, logger })
1099
1168
 
1100
1169
  // Hub からのメッセージ dispatch は、接続世代 + 機能レーン単位で直列実行する。
1101
1170
  //
@@ -1116,6 +1185,7 @@ export async function startDaemon({ version, claudeSdk } = {}) {
1116
1185
  client,
1117
1186
  claudeBridge,
1118
1187
  codexBridge,
1188
+ grokBridge,
1119
1189
  codexMcpControl,
1120
1190
  uploadManager,
1121
1191
  }
@@ -1148,7 +1218,7 @@ export async function startDaemon({ version, claudeSdk } = {}) {
1148
1218
  }, 60_000)
1149
1219
 
1150
1220
  // 会話の回収と常駐数制限を定期実行する。
1151
- const stateLoop = startStateLoop({ client, logger, intervalMs: 5_000, claudeBridge, codexBridge })
1221
+ const stateLoop = startStateLoop({ client, logger, intervalMs: 5_000, claudeBridge, codexBridge, grokBridge })
1152
1222
  // bundle hook (cockpit_session_event_hook.py) が /tmp/cockpit_session_events/<name>.json
1153
1223
  // に書き出す UserPromptSubmit / Stop / ready の event を fs.watch で拾って WS push する。
1154
1224
  // text マーカー判定 (detectStatusFromText) より精度が高い「ターン境界」の signal。
@@ -1164,9 +1234,10 @@ export async function startDaemon({ version, claudeSdk } = {}) {
1164
1234
  const siblingInjectBridge = new SiblingInjectBridge({
1165
1235
  claudeBridge,
1166
1236
  codexBridge,
1237
+ grokBridge,
1167
1238
  logger,
1168
1239
  starter: ({ cwd, cliKind }) => ensureChatSession({
1169
- cwd, cliKind, bridges: { claudeBridge, codexBridge }, logger,
1240
+ cwd, cliKind, bridges: { claudeBridge, codexBridge, grokBridge }, logger,
1170
1241
  resolveClaudeOptions: () => resolveClaudeAttachOptions(ctx.config),
1171
1242
  }),
1172
1243
  onRestartRequested: () => restartCoordinator.request(),
@@ -1209,6 +1280,7 @@ export async function startDaemon({ version, claudeSdk } = {}) {
1209
1280
  restartCoordinator?.stop?.()
1210
1281
  claudeBridge?.shutdown?.()
1211
1282
  codexBridge?.shutdown?.()
1283
+ await shutdownGrokBridge(grokBridge, logger)
1212
1284
  // 受理済み input_id の保存を待ってから exit する(再起動後の再送で二重実行しないため)。
1213
1285
  const persistence = await waitForClaudeInputPersistence()
1214
1286
  if (persistence === "timeout") {
@@ -1223,6 +1295,7 @@ export async function startDaemon({ version, claudeSdk } = {}) {
1223
1295
  restartCoordinator = new DeferredRestartCoordinator({
1224
1296
  claudeBridge,
1225
1297
  codexBridge,
1298
+ grokBridge,
1226
1299
  shutdown,
1227
1300
  logger,
1228
1301
  lifecycle,
@@ -1251,7 +1324,7 @@ export async function startDaemon({ version, claudeSdk } = {}) {
1251
1324
  )
1252
1325
  })
1253
1326
 
1254
- return { client, plugins, claudeBridge, codexBridge, codexMcpControl, uploadManager }
1327
+ return { client, plugins, claudeBridge, codexBridge, grokBridge, codexMcpControl, uploadManager }
1255
1328
  }
1256
1329
 
1257
1330
  const SESSION_EVENTS_DIR =
@@ -2160,7 +2233,7 @@ export function decideTurnActiveSource({
2160
2233
  return "none"
2161
2234
  }
2162
2235
 
2163
- export function startStateLoop({ client, logger, intervalMs, claudeBridge, codexBridge }) {
2236
+ export function startStateLoop({ client, logger, intervalMs, claudeBridge, codexBridge, grokBridge }) {
2164
2237
  // 総 live 上限 M のログ抑制状態。⚠️ **この state loop インスタンスが所有する**(module-global に
2165
2238
  // 置くと、複数インスタンスや並行テストが互いのログを抑制してしまう)。「超過しているが全部
2166
2239
  // 保護されていて刈れない」tick は 5 秒ごとに同じ行を出し続けるので、状態が変わったときと
@@ -2203,12 +2276,17 @@ export function startStateLoop({ client, logger, intervalMs, claudeBridge, codex
2203
2276
  } catch (err) {
2204
2277
  logger?.warn({ err: err?.message }, "gcAbandonedSessions failed")
2205
2278
  }
2279
+ try {
2280
+ grokBridge?.gcAbandonedSessions?.()
2281
+ } catch (err) {
2282
+ logger?.warn?.({ err: err?.message }, "grok gcAbandonedSessions failed")
2283
+ }
2206
2284
  // 総 live 上限 M(claude + codex 合算、既定 20)。per-cwd を 1 → K(既定 3)へ緩めたぶん、
2207
2285
  // cwd 単位の境界だけでは総量が膨らむため、cwd をまたいだ天井をここで適用する。
2208
2286
  // ⚠️ 両 CLI の reconcile が **終わった後**に呼ぶ(cwd 単位で畳んだ結果の生存数で判定する)。
2209
2287
  try {
2210
2288
  enforceTotalLiveBound({
2211
- bridges: [claudeBridge, codexBridge].filter(Boolean),
2289
+ bridges: [claudeBridge, codexBridge, grokBridge].filter(Boolean),
2212
2290
  logger,
2213
2291
  logState: warmBoundsLogState,
2214
2292
  })
@@ -2402,7 +2480,7 @@ export function handleStreamsSyncResponse(msg, ctx) {
2402
2480
  const activeIds = new Set(
2403
2481
  Array.isArray(msg?.active_stream_ids) ? msg.active_stream_ids : [],
2404
2482
  )
2405
- const activeByMode = { claude: new Set(), codex: new Set() }
2483
+ const activeByMode = { claude: new Set(), codex: new Set(), grok: new Set() }
2406
2484
  const classifiedIds = new Set()
2407
2485
  if (Array.isArray(msg?.active_streams)) {
2408
2486
  for (const item of msg.active_streams) {
@@ -2440,6 +2518,14 @@ export function handleStreamsSyncResponse(msg, ctx) {
2440
2518
  ctx.logger,
2441
2519
  )
2442
2520
  }
2521
+ if (activeByMode.grok.size > 0) {
2522
+ pruneInactiveChatSubscribers(
2523
+ ctx.grokBridge,
2524
+ activeByMode.grok,
2525
+ "grok",
2526
+ ctx.logger,
2527
+ )
2528
+ }
2443
2529
  }
2444
2530
  }
2445
2531
 
@@ -2495,6 +2581,7 @@ export function resetSeenClaudeChatInputIdsForTest() {
2495
2581
  seenClaudeChatInputIds = makePersistentSeenInputIdRegistry(SEEN_INPUT_IDS_MAX)
2496
2582
  }
2497
2583
  const seenCodexChatInputIds = makeSeenInputIdRegistry()
2584
+ const seenGrokChatInputIds = makeSeenInputIdRegistry()
2498
2585
 
2499
2586
  /**
2500
2587
  * 通常終了(SIGTERM・安全な再起動)の直前に、処理中の claude.input と受理済み ID の保存を待つ。
@@ -2888,6 +2975,126 @@ export function buildAttachHistoryPromise({
2888
2975
  }))
2889
2976
  }
2890
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
+
2891
3098
  /**
2892
3099
  * SDK チャット配達保証 (S2, 2026-07-05): `codex.input` を処理し、browser が付与した
2893
3100
  * `input_id` があれば `codex.input.ack` / `codex.input.nack` を返す (claude.input と
@@ -3437,9 +3644,11 @@ export async function stopChatSessionsUnder(dir, ctx, reason = "worktree-remove"
3437
3644
  const live = describeLiveSessionsUnion({
3438
3645
  claudeBridge: ctx.claudeBridge,
3439
3646
  codexBridge: ctx.codexBridge,
3647
+ grokBridge: ctx.grokBridge,
3440
3648
  })
3441
3649
  const claudeCwds = new Set()
3442
3650
  const codexStreams = new Set()
3651
+ const grokStreams = new Set()
3443
3652
  let stopped = 0
3444
3653
  // ⚠️ 例外を捕まえない。投げれば removeWorktree がその場で中断し、dir は残る (= 削除しない)。
3445
3654
  // 「失敗した分を集めて最後にまとめて投げる」形も書けるが、削除しないのであれば残りを止める
@@ -3460,6 +3669,10 @@ export async function stopChatSessionsUnder(dir, ctx, reason = "worktree-remove"
3460
3669
  stream_id: session.stream_id,
3461
3670
  reason,
3462
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 })
3463
3676
  }
3464
3677
  }
3465
3678
  return stopped
@@ -4014,6 +4227,16 @@ export async function dispatch(msg, ctx) {
4014
4227
  // async (claude 版は SDK 呼び出しが同期/fire-and-forget)。失敗時は codex.error で
4015
4228
  // browser に伝える。permission.reply の result は method ごとに shape が異なる
4016
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
4017
4240
  case "codex.attach": {
4018
4241
  const stream_id = msg.stream_id
4019
4242
  if (!ctx.codexBridge || !codexAvailableForDispatch(ctx)) {
@@ -4094,55 +4317,57 @@ export async function dispatch(msg, ctx) {
4094
4317
  }
4095
4318
  return
4096
4319
  }
4320
+ case "grok.upload":
4097
4321
  case "codex.upload": {
4098
- // B4 (T05699 残タスク, 2026-07-04): Cockpit Codexチャットの添付ファイル受信
4099
- // (チャンク1件)。claude.upload と同じ UploadManager を再利用する (cwd ベースで
4100
- // CLI 非依存の実装のため無改修で流用可能)。全チャンク到着で cwd 配下の
4101
- // .cockpit-uploads/<日付>/ に保存し、保存パスを browser へ返す。browser は
4102
- // そのパスを codex.input 本文に載せ、Codex がシェル/ファイルツールで読む
4103
- // (Claude 版と同じ「パス参照方式」、Codex 側の model/effort 分離と同様に
4104
- // アカウント/プロトコルが違うだけでファイル保存の仕組みは共有する)。
4105
- if (!ctx.uploadManager) {
4106
- ctx.client.send({
4107
- type: "codex.upload.done",
4108
- stream_id: msg.stream_id,
4109
- upload_id: msg.upload_id,
4110
- ok: false,
4111
- error: "upload_unavailable",
4112
- })
4113
- return
4114
- }
4115
- try {
4116
- const res = await ctx.uploadManager.addChunk({
4117
- upload_id: msg.upload_id,
4118
- filename: msg.filename,
4119
- media_type: msg.media_type,
4120
- total_size: msg.total_size,
4121
- total_chunks: msg.total_chunks,
4122
- chunk_index: msg.chunk_index,
4123
- data: msg.data,
4124
- cwd: msg.cwd || ctx.config?.claude_cwd || undefined,
4125
- })
4126
- if (res.done) {
4322
+ // パス参照方式の添付受信を共有し、返信型と既定 cwd だけを切り替える。
4323
+ async function handleUpload(doneType, getDefaultCwd) {
4324
+ if (!ctx.uploadManager) {
4127
4325
  ctx.client.send({
4128
- type: "codex.upload.done",
4326
+ type: doneType,
4129
4327
  stream_id: msg.stream_id,
4130
4328
  upload_id: msg.upload_id,
4131
- ok: true,
4132
- path: res.path,
4133
- filename: res.filename,
4134
- media_type: res.media_type,
4135
- 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),
4136
4364
  })
4137
4365
  }
4138
- } catch (err) {
4139
- ctx.client.send({
4140
- type: "codex.upload.done",
4141
- stream_id: msg.stream_id,
4142
- upload_id: msg.upload_id,
4143
- ok: false,
4144
- error: err?.message || String(err),
4145
- })
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)
4146
4371
  }
4147
4372
  return
4148
4373
  }
@@ -4779,11 +5004,13 @@ export async function dispatch(msg, ctx) {
4779
5004
  try {
4780
5005
  if (msg.scope === "cwd") {
4781
5006
  const cwd = typeof msg.cwd === "string" ? msg.cwd : ""
4782
- 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"
4783
5008
  else if (!cwd || !path.isAbsolute(cwd)) result.error = "cwd required"
4784
5009
  else if (msg.cli_kind === "claude") {
4785
5010
  noteRestoreStop(ctx.sessionRestoreState, "claude", cwd)
4786
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
4787
5014
  } else {
4788
5015
  noteRestoreStop(ctx.sessionRestoreState, "codex", cwd)
4789
5016
  result.stopped = await ctx.codexBridge.stopSessionsForCwd({ cwd, reason: "chat-stop" })
@@ -4801,6 +5028,9 @@ export async function dispatch(msg, ctx) {
4801
5028
  noteRestoreStop(ctx.sessionRestoreState, "claude", cwd)
4802
5029
  result.stopped = await ctx.claudeBridge.stopSessionForDeletion({ cwd, session_id, reason: "chat-stop" })
4803
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
4804
5034
  } else if (msg.cli_kind === "codex") {
4805
5035
  if (typeof msg.stream_id !== "string" || !msg.stream_id) result.error = "stream_id required"
4806
5036
  else {
@@ -4822,7 +5052,7 @@ export async function dispatch(msg, ctx) {
4822
5052
  case "chat.sessions.request": {
4823
5053
  try {
4824
5054
  const snapshot = await buildChatSessionsSnapshot({
4825
- bridges: { claudeBridge: ctx.claudeBridge, codexBridge: ctx.codexBridge },
5055
+ bridges: { claudeBridge: ctx.claudeBridge, codexBridge: ctx.codexBridge, grokBridge: ctx.grokBridge },
4826
5056
  logger: ctx.logger,
4827
5057
  })
4828
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