@cocorograph/hub-agent 0.8.114 → 0.8.116
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/src/chat-removal-guard.mjs +1 -1
- package/src/chat-session-starter.mjs +3 -2
- package/src/codex-binary.mjs +43 -27
- package/src/config.mjs +9 -4
- package/src/dispatch-scheduler.mjs +13 -11
- package/src/grok-acp-client.mjs +130 -0
- package/src/grok-binary.mjs +50 -0
- package/src/grok-stream-bridge.mjs +756 -0
- package/src/hub-bundle.mjs +183 -26
- package/src/live-sessions.mjs +12 -11
- package/src/main.mjs +310 -63
- package/src/review-event-store.mjs +1 -1
- package/src/session-names.mjs +21 -12
- package/src/sibling-inject.mjs +12 -8
- package/src/ws-client.mjs +26 -0
package/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
|
-
|
|
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 =
|
|
@@ -1501,14 +1574,31 @@ export function withSortAffectingAt(snapshot, getSortAt = getSortAffectingAt) {
|
|
|
1501
1574
|
const live = Array.isArray(snapshot?.live) ? snapshot.live : []
|
|
1502
1575
|
return {
|
|
1503
1576
|
...snapshot,
|
|
1504
|
-
live: live.map((item) =>
|
|
1505
|
-
const name = typeof item?.session_name === "string" ? item.session_name : ""
|
|
1506
|
-
const at = name ? getSortAt(name) : undefined
|
|
1507
|
-
return typeof at === "number" && at > 0 ? { ...item, sort_affecting_at: at } : item
|
|
1508
|
-
}),
|
|
1577
|
+
live: live.map((item) => attachSortAffectingAt(item, getSortAt)),
|
|
1509
1578
|
}
|
|
1510
1579
|
}
|
|
1511
1580
|
|
|
1581
|
+
/** 1 行 (`session_name` を持つ) に送信時刻を載せる。chat.sessions と sidebar_snapshot で共有する。 */
|
|
1582
|
+
function attachSortAffectingAt(item, getSortAt) {
|
|
1583
|
+
const name = typeof item?.session_name === "string" ? item.session_name : ""
|
|
1584
|
+
const at = name ? getSortAt(name) : undefined
|
|
1585
|
+
return typeof at === "number" && at > 0 ? { ...item, sort_affecting_at: at } : item
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
/**
|
|
1589
|
+
* `chat.sidebar_snapshot.response` の各行に、その行の「最後にユーザーが送信した時刻」
|
|
1590
|
+
* (`sort_affecting_at`) を載せる。
|
|
1591
|
+
*
|
|
1592
|
+
* `chat.sessions` は live な会話にしか送信時刻を載せない。サイドバーは、ユーザーが×で閉じて
|
|
1593
|
+
* いないのにプロセスが落ちて一覧から消えた会話も「起きている」段に残して送信時刻順に並べる
|
|
1594
|
+
* ため、再読み込み後はここでしか送信時刻を復元できない (2026-10-24)。
|
|
1595
|
+
* 値は withSortAffectingAt と同じ (sidecar で永続化済みのもの)。送信履歴の無い行は
|
|
1596
|
+
* キーごと付けない。
|
|
1597
|
+
*/
|
|
1598
|
+
export function withSnapshotSortAt(messages, getSortAt = getSortAffectingAt) {
|
|
1599
|
+
return (Array.isArray(messages) ? messages : []).map((item) => attachSortAffectingAt(item, getSortAt))
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1512
1602
|
// sort 時刻 sidecar の接尾辞。`.json` にしない (readAllSessionEvents / pushFile が
|
|
1513
1603
|
// `.endsWith(".json")` で event ファイルと誤認しないように)。
|
|
1514
1604
|
const SORT_AT_SUFFIX = ".sortat"
|
|
@@ -2160,7 +2250,7 @@ export function decideTurnActiveSource({
|
|
|
2160
2250
|
return "none"
|
|
2161
2251
|
}
|
|
2162
2252
|
|
|
2163
|
-
export function startStateLoop({ client, logger, intervalMs, claudeBridge, codexBridge }) {
|
|
2253
|
+
export function startStateLoop({ client, logger, intervalMs, claudeBridge, codexBridge, grokBridge }) {
|
|
2164
2254
|
// 総 live 上限 M のログ抑制状態。⚠️ **この state loop インスタンスが所有する**(module-global に
|
|
2165
2255
|
// 置くと、複数インスタンスや並行テストが互いのログを抑制してしまう)。「超過しているが全部
|
|
2166
2256
|
// 保護されていて刈れない」tick は 5 秒ごとに同じ行を出し続けるので、状態が変わったときと
|
|
@@ -2203,12 +2293,17 @@ export function startStateLoop({ client, logger, intervalMs, claudeBridge, codex
|
|
|
2203
2293
|
} catch (err) {
|
|
2204
2294
|
logger?.warn({ err: err?.message }, "gcAbandonedSessions failed")
|
|
2205
2295
|
}
|
|
2296
|
+
try {
|
|
2297
|
+
grokBridge?.gcAbandonedSessions?.()
|
|
2298
|
+
} catch (err) {
|
|
2299
|
+
logger?.warn?.({ err: err?.message }, "grok gcAbandonedSessions failed")
|
|
2300
|
+
}
|
|
2206
2301
|
// 総 live 上限 M(claude + codex 合算、既定 20)。per-cwd を 1 → K(既定 3)へ緩めたぶん、
|
|
2207
2302
|
// cwd 単位の境界だけでは総量が膨らむため、cwd をまたいだ天井をここで適用する。
|
|
2208
2303
|
// ⚠️ 両 CLI の reconcile が **終わった後**に呼ぶ(cwd 単位で畳んだ結果の生存数で判定する)。
|
|
2209
2304
|
try {
|
|
2210
2305
|
enforceTotalLiveBound({
|
|
2211
|
-
bridges: [claudeBridge, codexBridge].filter(Boolean),
|
|
2306
|
+
bridges: [claudeBridge, codexBridge, grokBridge].filter(Boolean),
|
|
2212
2307
|
logger,
|
|
2213
2308
|
logState: warmBoundsLogState,
|
|
2214
2309
|
})
|
|
@@ -2402,7 +2497,7 @@ export function handleStreamsSyncResponse(msg, ctx) {
|
|
|
2402
2497
|
const activeIds = new Set(
|
|
2403
2498
|
Array.isArray(msg?.active_stream_ids) ? msg.active_stream_ids : [],
|
|
2404
2499
|
)
|
|
2405
|
-
const activeByMode = { claude: new Set(), codex: new Set() }
|
|
2500
|
+
const activeByMode = { claude: new Set(), codex: new Set(), grok: new Set() }
|
|
2406
2501
|
const classifiedIds = new Set()
|
|
2407
2502
|
if (Array.isArray(msg?.active_streams)) {
|
|
2408
2503
|
for (const item of msg.active_streams) {
|
|
@@ -2440,6 +2535,14 @@ export function handleStreamsSyncResponse(msg, ctx) {
|
|
|
2440
2535
|
ctx.logger,
|
|
2441
2536
|
)
|
|
2442
2537
|
}
|
|
2538
|
+
if (activeByMode.grok.size > 0) {
|
|
2539
|
+
pruneInactiveChatSubscribers(
|
|
2540
|
+
ctx.grokBridge,
|
|
2541
|
+
activeByMode.grok,
|
|
2542
|
+
"grok",
|
|
2543
|
+
ctx.logger,
|
|
2544
|
+
)
|
|
2545
|
+
}
|
|
2443
2546
|
}
|
|
2444
2547
|
}
|
|
2445
2548
|
|
|
@@ -2495,6 +2598,7 @@ export function resetSeenClaudeChatInputIdsForTest() {
|
|
|
2495
2598
|
seenClaudeChatInputIds = makePersistentSeenInputIdRegistry(SEEN_INPUT_IDS_MAX)
|
|
2496
2599
|
}
|
|
2497
2600
|
const seenCodexChatInputIds = makeSeenInputIdRegistry()
|
|
2601
|
+
const seenGrokChatInputIds = makeSeenInputIdRegistry()
|
|
2498
2602
|
|
|
2499
2603
|
/**
|
|
2500
2604
|
* 通常終了(SIGTERM・安全な再起動)の直前に、処理中の claude.input と受理済み ID の保存を待つ。
|
|
@@ -2888,6 +2992,126 @@ export function buildAttachHistoryPromise({
|
|
|
2888
2992
|
}))
|
|
2889
2993
|
}
|
|
2890
2994
|
|
|
2995
|
+
/**
|
|
2996
|
+
* 要求の失敗を、bridge が通知するターンの失敗(phase なし)と区別する印。
|
|
2997
|
+
* attach は接続そのものの失敗。それ以外の要求の失敗はターンを終わらせない(browser はターン終了に使わない)。
|
|
2998
|
+
*/
|
|
2999
|
+
function grokRequestPhase(type) {
|
|
3000
|
+
return type === "grok.attach" ? { phase: "attach" } : { phase: "request", request_type: type }
|
|
3001
|
+
}
|
|
3002
|
+
|
|
3003
|
+
export async function handleGrokMessage(msg, ctx) {
|
|
3004
|
+
const bridge = ctx.grokBridge
|
|
3005
|
+
const stream_id = msg.stream_id
|
|
3006
|
+
const send = (payload) => ctx.client.send({ ...payload, stream_id })
|
|
3007
|
+
if (msg.type === "grok.queue.cancel") {
|
|
3008
|
+
const response = { type: "grok.queue.response", request_id: msg.request_id || null, input_id: msg.id || null }
|
|
3009
|
+
let canceled
|
|
3010
|
+
try {
|
|
3011
|
+
canceled = bridge?.cancelQueued({ stream_id, id: msg.id })
|
|
3012
|
+
} catch (err) {
|
|
3013
|
+
ctx.logger?.warn?.(
|
|
3014
|
+
{ err: err?.message, stream_id, input_id: msg.id },
|
|
3015
|
+
"grok queued input cancel failed",
|
|
3016
|
+
)
|
|
3017
|
+
send({ ...response, error: err?.message || "cancel_failed" })
|
|
3018
|
+
return
|
|
3019
|
+
}
|
|
3020
|
+
send({ ...response, outcome: canceled?.outcome || "stream_missing" })
|
|
3021
|
+
canceled?.flush?.()
|
|
3022
|
+
return
|
|
3023
|
+
}
|
|
3024
|
+
if (msg.type === "grok.sessions.request") {
|
|
3025
|
+
const cwd = msg.cwd || ""
|
|
3026
|
+
const epoch = typeof msg.epoch === "number" ? msg.epoch : undefined
|
|
3027
|
+
const response = { type: "grok.sessions.response", cwd, epoch, sessions: [], session_operations: false }
|
|
3028
|
+
try {
|
|
3029
|
+
if (!bridge) throw new Error("grok_unavailable")
|
|
3030
|
+
const rows = await bridge.listThreads({ cwd, limit: typeof msg.limit === "number" ? msg.limit : undefined })
|
|
3031
|
+
const liveByThread = new Map(bridge.describeLiveSessions(cwd).map((s) => [s.thread_id, s]))
|
|
3032
|
+
response.sessions = rows.filter((row) => typeof row?.sessionId === "string").map((row) => {
|
|
3033
|
+
const updated = Date.parse(row.updatedAt)
|
|
3034
|
+
const live = liveByThread.get(row.sessionId)
|
|
3035
|
+
return { thread_id: row.sessionId, cwd: row.cwd ?? cwd,
|
|
3036
|
+
preview: typeof row.title === "string" ? row.title : "",
|
|
3037
|
+
updated_at_ms: Number.isFinite(updated) ? updated : null,
|
|
3038
|
+
live: !!live, busy: !!live?.busy }
|
|
3039
|
+
})
|
|
3040
|
+
} catch (err) {
|
|
3041
|
+
response.error = err?.message || String(err)
|
|
3042
|
+
}
|
|
3043
|
+
send(response)
|
|
3044
|
+
return
|
|
3045
|
+
}
|
|
3046
|
+
if (!bridge) {
|
|
3047
|
+
// Codex と同じく、input_id 付きの入力には NACK を返す(返さないと browser の再送が止まらない)。
|
|
3048
|
+
const input_id = msg.type === "grok.input" && typeof msg.input_id === "string" && msg.input_id ? msg.input_id : null
|
|
3049
|
+
if (input_id) send({ type: "grok.input.nack", input_id, reason: "stream_missing" })
|
|
3050
|
+
else send({ type: "grok.error", error: "grok_unavailable", ...grokRequestPhase(msg.type) })
|
|
3051
|
+
return
|
|
3052
|
+
}
|
|
3053
|
+
if (msg.type === "grok.input") {
|
|
3054
|
+
const input_id = typeof msg.input_id === "string" && msg.input_id ? msg.input_id : null
|
|
3055
|
+
if (input_id && seenGrokChatInputIds.has(input_id)) {
|
|
3056
|
+
send({ type: "grok.input.ack", input_id })
|
|
3057
|
+
return
|
|
3058
|
+
}
|
|
3059
|
+
try {
|
|
3060
|
+
const rejection = inputLifecycleRejection(ctx)
|
|
3061
|
+
if (rejection) throw new Error(rejection.reason)
|
|
3062
|
+
// 受付は同期で完了するため、ターン処理中の再送も上の受理済み ID で除外する。
|
|
3063
|
+
// ターンの失敗は bridge が監視し、grok_error で通知する。
|
|
3064
|
+
bridge.input({ stream_id, message: msg.message, inputId: input_id || undefined,
|
|
3065
|
+
model: msg.model, effort: msg.effort })
|
|
3066
|
+
if (input_id) {
|
|
3067
|
+
seenGrokChatInputIds.remember(input_id)
|
|
3068
|
+
send({ type: "grok.input.ack", input_id })
|
|
3069
|
+
}
|
|
3070
|
+
} catch (err) {
|
|
3071
|
+
if (input_id) send({ type: "grok.input.nack", input_id, reason: err?.message || String(err) })
|
|
3072
|
+
else send({ type: "grok.error", error: err?.message || String(err), ...grokRequestPhase(msg.type) })
|
|
3073
|
+
}
|
|
3074
|
+
return
|
|
3075
|
+
}
|
|
3076
|
+
if (msg.type === "grok.interrupt") {
|
|
3077
|
+
try {
|
|
3078
|
+
await bridge.interrupt({ stream_id })
|
|
3079
|
+
send({ type: "grok.interrupt.response", ok: true })
|
|
3080
|
+
} catch (err) {
|
|
3081
|
+
send({ type: "grok.interrupt.response", ok: false, error: err?.message || String(err) })
|
|
3082
|
+
}
|
|
3083
|
+
return
|
|
3084
|
+
}
|
|
3085
|
+
try {
|
|
3086
|
+
switch (msg.type) {
|
|
3087
|
+
case "grok.attach": {
|
|
3088
|
+
// claude_cwd → HOME の補完。scheduler のレーン分類と同じ defaultLaneCwd を使う。
|
|
3089
|
+
const cwd = msg.cwd || defaultLaneCwd(ctx.config, "grok")
|
|
3090
|
+
const info = await bridge.attach({ stream_id, cwd, model: msg.model, effort: msg.effort,
|
|
3091
|
+
resumeSessionId: msg.resume_session_id, attachId: msg.attach_id })
|
|
3092
|
+
send({ type: "grok.ready", thread_id: info.sessionId, resuming: info.resuming,
|
|
3093
|
+
warm: info.warm, history: info.history, history_truncated: info.historyTruncated,
|
|
3094
|
+
last_seq: info.lastSeq, turn_active: info.turnActive,
|
|
3095
|
+
queue_state: info.queueState, supports_queue_cancel_response: true })
|
|
3096
|
+
break
|
|
3097
|
+
}
|
|
3098
|
+
case "grok.permission.reply":
|
|
3099
|
+
await bridge.permissionReply({ stream_id, request_id: msg.request_id, result: msg.result })
|
|
3100
|
+
break
|
|
3101
|
+
case "grok.detach":
|
|
3102
|
+
await bridge.detach({ stream_id })
|
|
3103
|
+
break
|
|
3104
|
+
case "grok.keepalive":
|
|
3105
|
+
await bridge.keepalive({ stream_id })
|
|
3106
|
+
break
|
|
3107
|
+
}
|
|
3108
|
+
} catch (err) {
|
|
3109
|
+
// phase: "attach" は接続そのものの失敗(backend はこの stream の登録を外す)。それ以外の要求の
|
|
3110
|
+
// 失敗とターン失敗(bridge の grok_error)は会話が続くので、登録を外させない。
|
|
3111
|
+
send({ type: "grok.error", error: err?.message || String(err), ...grokRequestPhase(msg.type) })
|
|
3112
|
+
}
|
|
3113
|
+
}
|
|
3114
|
+
|
|
2891
3115
|
/**
|
|
2892
3116
|
* SDK チャット配達保証 (S2, 2026-07-05): `codex.input` を処理し、browser が付与した
|
|
2893
3117
|
* `input_id` があれば `codex.input.ack` / `codex.input.nack` を返す (claude.input と
|
|
@@ -3437,9 +3661,11 @@ export async function stopChatSessionsUnder(dir, ctx, reason = "worktree-remove"
|
|
|
3437
3661
|
const live = describeLiveSessionsUnion({
|
|
3438
3662
|
claudeBridge: ctx.claudeBridge,
|
|
3439
3663
|
codexBridge: ctx.codexBridge,
|
|
3664
|
+
grokBridge: ctx.grokBridge,
|
|
3440
3665
|
})
|
|
3441
3666
|
const claudeCwds = new Set()
|
|
3442
3667
|
const codexStreams = new Set()
|
|
3668
|
+
const grokStreams = new Set()
|
|
3443
3669
|
let stopped = 0
|
|
3444
3670
|
// ⚠️ 例外を捕まえない。投げれば removeWorktree がその場で中断し、dir は残る (= 削除しない)。
|
|
3445
3671
|
// 「失敗した分を集めて最後にまとめて投げる」形も書けるが、削除しないのであれば残りを止める
|
|
@@ -3460,6 +3686,10 @@ export async function stopChatSessionsUnder(dir, ctx, reason = "worktree-remove"
|
|
|
3460
3686
|
stream_id: session.stream_id,
|
|
3461
3687
|
reason,
|
|
3462
3688
|
})
|
|
3689
|
+
} else if (session.kind === "grok") {
|
|
3690
|
+
if (grokStreams.has(session.stream_id)) continue
|
|
3691
|
+
grokStreams.add(session.stream_id)
|
|
3692
|
+
stopped += await ctx.grokBridge.stopSessionForStream({ stream_id: session.stream_id, reason })
|
|
3463
3693
|
}
|
|
3464
3694
|
}
|
|
3465
3695
|
return stopped
|
|
@@ -4014,6 +4244,16 @@ export async function dispatch(msg, ctx) {
|
|
|
4014
4244
|
// async (claude 版は SDK 呼び出しが同期/fire-and-forget)。失敗時は codex.error で
|
|
4015
4245
|
// browser に伝える。permission.reply の result は method ごとに shape が異なる
|
|
4016
4246
|
// (accept/decline 等) ため、正規化せず browser が組み立てたものをそのまま渡す。
|
|
4247
|
+
case "grok.attach":
|
|
4248
|
+
case "grok.input":
|
|
4249
|
+
case "grok.queue.cancel":
|
|
4250
|
+
case "grok.interrupt":
|
|
4251
|
+
case "grok.permission.reply":
|
|
4252
|
+
case "grok.detach":
|
|
4253
|
+
case "grok.keepalive":
|
|
4254
|
+
case "grok.sessions.request":
|
|
4255
|
+
await handleGrokMessage(msg, ctx)
|
|
4256
|
+
return
|
|
4017
4257
|
case "codex.attach": {
|
|
4018
4258
|
const stream_id = msg.stream_id
|
|
4019
4259
|
if (!ctx.codexBridge || !codexAvailableForDispatch(ctx)) {
|
|
@@ -4094,55 +4334,57 @@ export async function dispatch(msg, ctx) {
|
|
|
4094
4334
|
}
|
|
4095
4335
|
return
|
|
4096
4336
|
}
|
|
4337
|
+
case "grok.upload":
|
|
4097
4338
|
case "codex.upload": {
|
|
4098
|
-
//
|
|
4099
|
-
|
|
4100
|
-
|
|
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) {
|
|
4339
|
+
// パス参照方式の添付受信を共有し、返信型と既定 cwd だけを切り替える。
|
|
4340
|
+
async function handleUpload(doneType, getDefaultCwd) {
|
|
4341
|
+
if (!ctx.uploadManager) {
|
|
4127
4342
|
ctx.client.send({
|
|
4128
|
-
type:
|
|
4343
|
+
type: doneType,
|
|
4129
4344
|
stream_id: msg.stream_id,
|
|
4130
4345
|
upload_id: msg.upload_id,
|
|
4131
|
-
ok:
|
|
4132
|
-
|
|
4133
|
-
filename: res.filename,
|
|
4134
|
-
media_type: res.media_type,
|
|
4135
|
-
size: res.size,
|
|
4346
|
+
ok: false,
|
|
4347
|
+
error: "upload_unavailable",
|
|
4136
4348
|
})
|
|
4349
|
+
return
|
|
4137
4350
|
}
|
|
4138
|
-
|
|
4139
|
-
|
|
4140
|
-
|
|
4141
|
-
|
|
4142
|
-
|
|
4143
|
-
|
|
4144
|
-
|
|
4145
|
-
|
|
4351
|
+
try {
|
|
4352
|
+
const res = await ctx.uploadManager.addChunk({
|
|
4353
|
+
upload_id: msg.upload_id,
|
|
4354
|
+
filename: msg.filename,
|
|
4355
|
+
media_type: msg.media_type,
|
|
4356
|
+
total_size: msg.total_size,
|
|
4357
|
+
total_chunks: msg.total_chunks,
|
|
4358
|
+
chunk_index: msg.chunk_index,
|
|
4359
|
+
data: msg.data,
|
|
4360
|
+
cwd: msg.cwd || getDefaultCwd(),
|
|
4361
|
+
})
|
|
4362
|
+
if (res.done) {
|
|
4363
|
+
ctx.client.send({
|
|
4364
|
+
type: doneType,
|
|
4365
|
+
stream_id: msg.stream_id,
|
|
4366
|
+
upload_id: msg.upload_id,
|
|
4367
|
+
ok: true,
|
|
4368
|
+
path: res.path,
|
|
4369
|
+
filename: res.filename,
|
|
4370
|
+
media_type: res.media_type,
|
|
4371
|
+
size: res.size,
|
|
4372
|
+
})
|
|
4373
|
+
}
|
|
4374
|
+
} catch (err) {
|
|
4375
|
+
ctx.client.send({
|
|
4376
|
+
type: doneType,
|
|
4377
|
+
stream_id: msg.stream_id,
|
|
4378
|
+
upload_id: msg.upload_id,
|
|
4379
|
+
ok: false,
|
|
4380
|
+
error: err?.message || String(err),
|
|
4381
|
+
})
|
|
4382
|
+
}
|
|
4383
|
+
}
|
|
4384
|
+
if (msg.type === "grok.upload") {
|
|
4385
|
+
await handleUpload("grok.upload.done", () => defaultLaneCwd(ctx.config, "grok"))
|
|
4386
|
+
} else {
|
|
4387
|
+
await handleUpload("codex.upload.done", () => ctx.config?.claude_cwd || undefined)
|
|
4146
4388
|
}
|
|
4147
4389
|
return
|
|
4148
4390
|
}
|
|
@@ -4779,11 +5021,13 @@ export async function dispatch(msg, ctx) {
|
|
|
4779
5021
|
try {
|
|
4780
5022
|
if (msg.scope === "cwd") {
|
|
4781
5023
|
const cwd = typeof msg.cwd === "string" ? msg.cwd : ""
|
|
4782
|
-
if (msg.cli_kind !== "claude" && msg.cli_kind !== "codex") result.error = "unsupported cli_kind"
|
|
5024
|
+
if (msg.cli_kind !== "claude" && msg.cli_kind !== "codex" && msg.cli_kind !== "grok") result.error = "unsupported cli_kind"
|
|
4783
5025
|
else if (!cwd || !path.isAbsolute(cwd)) result.error = "cwd required"
|
|
4784
5026
|
else if (msg.cli_kind === "claude") {
|
|
4785
5027
|
noteRestoreStop(ctx.sessionRestoreState, "claude", cwd)
|
|
4786
5028
|
result.stopped = await ctx.claudeBridge.stopSessionsFor({ cwd, reason: "chat-stop" })
|
|
5029
|
+
} else if (msg.cli_kind === "grok") {
|
|
5030
|
+
result.stopped = await ctx.grokBridge?.stopSessionsForCwd({ cwd, reason: "chat-stop" }) ?? 0
|
|
4787
5031
|
} else {
|
|
4788
5032
|
noteRestoreStop(ctx.sessionRestoreState, "codex", cwd)
|
|
4789
5033
|
result.stopped = await ctx.codexBridge.stopSessionsForCwd({ cwd, reason: "chat-stop" })
|
|
@@ -4801,6 +5045,9 @@ export async function dispatch(msg, ctx) {
|
|
|
4801
5045
|
noteRestoreStop(ctx.sessionRestoreState, "claude", cwd)
|
|
4802
5046
|
result.stopped = await ctx.claudeBridge.stopSessionForDeletion({ cwd, session_id, reason: "chat-stop" })
|
|
4803
5047
|
}
|
|
5048
|
+
} else if (msg.cli_kind === "grok") {
|
|
5049
|
+
if (typeof msg.stream_id !== "string" || !msg.stream_id) result.error = "stream_id required"
|
|
5050
|
+
else result.stopped = await ctx.grokBridge?.stopSessionForStream({ stream_id: msg.stream_id, reason: "chat-stop" }) ?? 0
|
|
4804
5051
|
} else if (msg.cli_kind === "codex") {
|
|
4805
5052
|
if (typeof msg.stream_id !== "string" || !msg.stream_id) result.error = "stream_id required"
|
|
4806
5053
|
else {
|
|
@@ -4822,7 +5069,7 @@ export async function dispatch(msg, ctx) {
|
|
|
4822
5069
|
case "chat.sessions.request": {
|
|
4823
5070
|
try {
|
|
4824
5071
|
const snapshot = await buildChatSessionsSnapshot({
|
|
4825
|
-
bridges: { claudeBridge: ctx.claudeBridge, codexBridge: ctx.codexBridge },
|
|
5072
|
+
bridges: { claudeBridge: ctx.claudeBridge, codexBridge: ctx.codexBridge, grokBridge: ctx.grokBridge },
|
|
4826
5073
|
logger: ctx.logger,
|
|
4827
5074
|
})
|
|
4828
5075
|
ctx.client.send({ type: "chat.sessions", request_id: msg.request_id, ...withSortAffectingAt(refreshChatSessionsSignals(snapshot, ctx.logger)) })
|
|
@@ -4836,10 +5083,10 @@ export async function dispatch(msg, ctx) {
|
|
|
4836
5083
|
}
|
|
4837
5084
|
case "chat.sidebar_snapshot.request": {
|
|
4838
5085
|
try {
|
|
4839
|
-
const messages = await readSidebarMessageSnapshot(msg.targets, {
|
|
5086
|
+
const messages = withSnapshotSortAt(await readSidebarMessageSnapshot(msg.targets, {
|
|
4840
5087
|
claudeBridge: ctx.claudeBridge,
|
|
4841
5088
|
codexBridge: ctx.codexBridge,
|
|
4842
|
-
})
|
|
5089
|
+
}))
|
|
4843
5090
|
ctx.client.send({
|
|
4844
5091
|
type: "chat.sidebar_snapshot.response",
|
|
4845
5092
|
request_id: msg.request_id,
|
|
@@ -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
|