@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.
@@ -0,0 +1,756 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { EventEmitter } from 'node:events'
3
+ import { readPositiveIntEnv, sanitizePositiveInt } from './positive-int-env.mjs'
4
+ import { GrokAcpClient } from './grok-acp-client.mjs'
5
+ import { isUnderRemoval } from './chat-removal-guard.mjs'
6
+ import { getOrCreateSiblingToken } from './sibling-token.mjs'
7
+
8
+ // hub-bundle.mjs の HUB_MCP_NAMES と揃える。重い依存を避け、ここで定義する。
9
+ const HUB_MCP_NAMES = new Set(['hub', 'hub_staff'])
10
+
11
+ function siblingEnvFor(cwd, inherited = process.env) {
12
+ const env = {
13
+ ...inherited,
14
+ COCKPIT_SIBLING_TOKEN: getOrCreateSiblingToken(cwd),
15
+ COCKPIT_SIBLING_CWD: cwd,
16
+ COCKPIT_REVIEW_EVENTS: '1',
17
+ COCKPIT_DELEGATE_EVENT_ACK: '1',
18
+ COCKPIT_SIBLING_SELF_KIND: 'grok',
19
+ }
20
+ delete env.CLAUDE_CODE_SESSION_ID
21
+ delete env.CODEX_THREAD_ID
22
+ return env
23
+ }
24
+
25
+ // 完了済み配送 ID の保持上限。上限を超えた古い完了済み ID の再送は再実行されうる。
26
+ const MAX_ACCEPTED_REQUEST_IDS = 500
27
+ // 未完了(実行中・キュー待ち)の配送 ID の上限。超えたら受理せず busy を返す(未完了 ID は追い出さない)。
28
+ const MAX_PENDING_DELIVERIES = 100
29
+ // 連結した 1 件の履歴項目の上限(UTF-8 バイト)。超えるなら新しい項目に分けて、末尾 1 件にも
30
+ // 履歴全体の上限が効くようにする(連結し続けると末尾 1 件が無制限に育つため)。
31
+ const HISTORY_MERGE_MAX_BYTES = 64 * 1024
32
+ const utf8Bytes = (value) => Buffer.byteLength(JSON.stringify(value), 'utf8')
33
+
34
+ // Only extensions observed with Build 1.0.67 may receive an empty success response.
35
+ const ignoredRequests = new Set([
36
+ 'session/setup', 'mcp/init_progress', 'sessions/changed', 'queue/changed',
37
+ 'mcp/server_status', 'models/update', 'settings/update', 'announcements/update',
38
+ 'session_notification', 'mcp_initialized', 'session/prompt_complete',
39
+ ].map((name) => `_x.ai/${name}`))
40
+
41
+ function promptFromMessage(message) {
42
+ const content = typeof message === 'string' ? message : message?.content
43
+ if (Array.isArray(content)) {
44
+ return content.filter((block) => block && block.type === 'text' && typeof block.text === 'string')
45
+ .map((block) => ({ type: 'text', text: block.text }))
46
+ }
47
+ return typeof content === 'string' && content.length ? [{ type: 'text', text: content }] : []
48
+ }
49
+
50
+ export class GrokStreamBridge extends EventEmitter {
51
+ constructor({ grokCmd, env, args, requestTimeoutMs, logger, detachTtlMs, detachedReapTtlMs,
52
+ stuckTurnTtlMs, deadTerminalTtlMs, warmCwdMax, warmPerCwdMax, historyMaxItems, historyMaxBytes } = {}) {
53
+ super()
54
+ this.options = { grokCmd, env, args, requestTimeoutMs, logger }
55
+ this.sessions = new Map()
56
+ this.clients = new Set()
57
+ this.closed = false
58
+ this.sessionSeq = 0
59
+ const bound = (value, name, fallback) => sanitizePositiveInt(value,
60
+ readPositiveIntEnv(name, fallback, logger), { logger, label: name })
61
+ this._detachTtlMs = bound(detachTtlMs, 'HUB_AGENT_GROK_IDLE_DETACH_TTL_MS', 7 * 86400000)
62
+ this._detachedReapTtlMs = bound(detachedReapTtlMs, 'HUB_AGENT_GROK_DETACHED_REAP_TTL_MS', 30 * 60000)
63
+ this._stuckTurnTtlMs = bound(stuckTurnTtlMs, 'HUB_AGENT_GROK_STUCK_TURN_TTL_MS', 3600000)
64
+ this._deadTerminalTtlMs = bound(deadTerminalTtlMs, 'HUB_AGENT_GROK_DEAD_TERMINAL_TTL_MS', 3600000)
65
+ this._warmCwdMax = bound(warmCwdMax, 'HUB_AGENT_WARM_CWD_MAX', 20)
66
+ this._warmPerCwdMax = bound(warmPerCwdMax, 'HUB_AGENT_WARM_PER_CWD_MAX', 3)
67
+ this._historyMaxItems = bound(historyMaxItems, 'HUB_AGENT_GROK_HISTORY_MAX_ITEMS', 200)
68
+ this._historyMaxBytes = bound(historyMaxBytes, 'HUB_AGENT_GROK_HISTORY_MAX_BYTES', 1.5 * 1024 * 1024)
69
+ this.listRequests = new Map()
70
+ // Clients whose process group survived SIGKILL. Kept for diagnostics; never re-signalled.
71
+ this.unreaped = new Set()
72
+ }
73
+
74
+ _client(cwd, session) {
75
+ if (this.closed) throw new Error('Grok bridge is shut down')
76
+ // Each conversation owns its process; bind the capability to that process's cwd.
77
+ const client = new GrokAcpClient({ ...this.options, cwd,
78
+ env: siblingEnvFor(cwd || process.cwd(), this.options.env) })
79
+ this.clients.add(client)
80
+ // Before the session is ready, a transport error or exit rejects the pending attach request;
81
+ // the attach caller reports it once (phase=attach), so the bridge stays silent here.
82
+ client.on('error', (error) => {
83
+ if (!session || session.readyDone) this.emit('grok_error', { stream_id: session?.stream_id, message: error.message })
84
+ void this._dispose(client)
85
+ })
86
+ client.on('exit', ({ code, signal }) => {
87
+ if (session && !session.dead && !session.readyDone) {
88
+ this._forget(session)
89
+ } else if (session && !session.dead) {
90
+ this._forget(session)
91
+ this.emit('grok_error', { stream_id: session.stream_id, session_id: session.sessionId, message: `grok ACP exited (code=${code}, signal=${signal})` })
92
+ this.emit('grok_exit', { cwd: session.cwd, stream_id: session.stream_id,
93
+ session_id: session.sessionId, reason: 'process-exit', code, signal, lastSeq: session.updateSeq })
94
+ }
95
+ void this._dispose(client)
96
+ })
97
+ client.on('request', ({ id, method, params }) => {
98
+ if (method === 'session/request_permission' && session && !session.dead) {
99
+ if (session.turnSeq === session.cancelledTurn) {
100
+ client.respond(id, { outcome: { outcome: 'cancelled' } })
101
+ return
102
+ }
103
+ const token = randomUUID()
104
+ session.pendingPermissions.set(token, { rpcId: id, turn: session.turnSeq, params })
105
+ this.emit('grok_permission', { stream_id: session.stream_id, request_id: token, params })
106
+ } else if (ignoredRequests.has(method)) client.respond(id, {})
107
+ else client.respondError(id, { code: -32601, message: 'Method not found' })
108
+ })
109
+ const authTriggered = new Set()
110
+ client.on('notification', ({ method, params }) => {
111
+ if (method === '_x.ai/mcp/server_status' && session && !session.dead &&
112
+ params?.status === 'unavailable' && HUB_MCP_NAMES.has(params.name) &&
113
+ typeof params.sessionId === 'string') {
114
+ const key = JSON.stringify([params.sessionId, params.name])
115
+ if (!authTriggered.has(key)) {
116
+ authTriggered.add(key)
117
+ void Promise.resolve().then(() => {
118
+ if (session.dead) return
119
+ return client.request('_x.ai/mcp/auth_trigger', {
120
+ session_id: params.sessionId, server_name: params.name,
121
+ }, { timeoutMs: 300000 })
122
+ }).then((result) => {
123
+ if (result) this.options.logger?.info?.({ name: params.name, status: result.status }, 'grok MCP authentication result')
124
+ }).catch(() => {
125
+ this.options.logger?.warn?.({ name: params.name }, 'grok MCP authentication failed')
126
+ })
127
+ }
128
+ }
129
+ if (method === 'session/update' && session && !session.dead) {
130
+ if (!params || typeof params !== 'object' || Array.isArray(params) || typeof params.sessionId !== 'string' ||
131
+ !params.update || typeof params.update !== 'object' || Array.isArray(params.update)) {
132
+ this.options.logger?.warn?.('grok ACP: invalid session/update skipped')
133
+ return
134
+ }
135
+ this._touch(session)
136
+ this._recordUpdate(session, params.update)
137
+ if (session.readyDone) this.emit('grok_event', { stream_id: session.stream_id,
138
+ session_id: params.sessionId, seq: session.updateSeq, update: params.update })
139
+ }
140
+ })
141
+ return client
142
+ }
143
+
144
+ _liveSessions() { return [...new Set(this.sessions.values())].filter((s) => !s.dead) }
145
+
146
+ _touch(session, stream_id) {
147
+ const now = Date.now()
148
+ session.lastActivityMs = now
149
+ if (session.streamIds.has(stream_id)) session.lastActivityByStream.set(stream_id, now)
150
+ }
151
+
152
+ // historyBytes is maintained incrementally as the UTF-8 size of JSON.stringify(historyItems);
153
+ // re-serialising the whole history per streamed chunk would block the event loop shared with
154
+ // Claude/Codex.
155
+ _recordUpdate(session, update) {
156
+ const seq = ++session.updateSeq
157
+ const at_ms = Date.now()
158
+ const previous = session.historyItems.at(-1)
159
+ // Chunks merge only within one history epoch (bumped at load completion, turn start and turn
160
+ // end) so replies from different turns never become one item, and only while the merged item
161
+ // stays under HISTORY_MERGE_MAX_BYTES so the overall bound also applies to the last item.
162
+ const mergeable = session.historyMergeEpoch === session.historyEpoch &&
163
+ previous?.update.sessionUpdate === update.sessionUpdate &&
164
+ ['agent_message_chunk', 'agent_thought_chunk'].includes(update.sessionUpdate) &&
165
+ previous.update.content?.type === 'text' && update.content?.type === 'text' &&
166
+ typeof previous.update.content.text === 'string' && typeof update.content.text === 'string'
167
+ // Exact for concatenation except a surrogate pair split across chunks (an estimate is enough).
168
+ const delta = mergeable
169
+ ? utf8Bytes(update.content.text) - 2 + String(seq).length - String(previous.seq).length +
170
+ String(at_ms).length - String(previous.at_ms).length
171
+ : 0
172
+ // The merged item must also fit the configured total (minus the enclosing "[]"), otherwise a
173
+ // small historyMaxBytes is exceeded by merging alone (the last item is never dropped).
174
+ const mergeCap = Math.min(HISTORY_MERGE_MAX_BYTES, this._historyMaxBytes - 2)
175
+ if (mergeable && session.historyItemBytes.at(-1) + delta <= mergeCap) {
176
+ previous.update.content.text += update.content.text
177
+ previous.seq = seq
178
+ previous.at_ms = at_ms
179
+ session.historyItemBytes[session.historyItemBytes.length - 1] += delta
180
+ session.historyBytes += delta
181
+ } else {
182
+ const entry = { seq, update: structuredClone(update), at_ms }
183
+ const bytes = utf8Bytes(entry)
184
+ session.historyItems.push(entry)
185
+ session.historyItemBytes.push(bytes)
186
+ session.historyBytes += bytes + (session.historyItems.length > 1 ? 1 : 0)
187
+ session.historyMergeEpoch = session.historyEpoch
188
+ }
189
+ while (session.historyItems.length > 1 && (session.historyItems.length > this._historyMaxItems ||
190
+ session.historyBytes > this._historyMaxBytes)) {
191
+ session.historyItems.shift()
192
+ session.historyBytes -= session.historyItemBytes.shift() + 1
193
+ session.historyTruncated = true
194
+ }
195
+ }
196
+
197
+ _snapshot(session, warm) {
198
+ return { sessionId: session.sessionId, resuming: warm || !!session.resumeSessionId, warm,
199
+ history: structuredClone(session.historyItems), historyTruncated: session.historyTruncated,
200
+ lastSeq: session.updateSeq, turnActive: warm && session.promptInFlight,
201
+ queueState: this._queueStateOf(session) }
202
+ }
203
+
204
+ _forget(session) {
205
+ session.dead = true
206
+ for (const item of session.inputQueue.splice(0)) {
207
+ item.reject(new Error('Grok session detached'))
208
+ }
209
+ session.acceptedRequestIds.clear()
210
+ for (const entry of session.pendingPermissions.values()) {
211
+ session.client.respond(entry.rpcId, { outcome: { outcome: 'cancelled' } })
212
+ }
213
+ session.pendingPermissions.clear()
214
+ for (const sid of session.streamIds) {
215
+ if (this.sessions.get(sid) === session) this.sessions.delete(sid)
216
+ }
217
+ }
218
+
219
+ async _dispose(client) {
220
+ for (const session of this._liveSessions()) {
221
+ if (session.client === client) this._forget(session)
222
+ }
223
+ let reaped = false
224
+ try { reaped = await client.stop() } catch { /* Retain failed cleanup in unreaped diagnostics. */ }
225
+ if (this.clients.delete(client) && !reaped) {
226
+ this.unreaped.add(client)
227
+ this.emit('grok_error', { message: 'grok ACP process group could not be reaped' })
228
+ }
229
+ return reaped
230
+ }
231
+
232
+ async _initialize(client) {
233
+ client.start()
234
+ return client.request('initialize', { protocolVersion: 1, clientCapabilities: {} })
235
+ }
236
+
237
+ _applyConfig(session, { model, effort }) {
238
+ const changes = [['model', model], ['reasoning_effort', effort]]
239
+ .filter(([id, value]) => value && value !== session.config?.[id])
240
+ if (!changes.length) return null
241
+ return (async () => {
242
+ for (const [configId, value] of changes) {
243
+ await session.client.request('session/set_config_option', {
244
+ sessionId: session.sessionId, configId, value,
245
+ })
246
+ session.config ??= {}
247
+ session.config[configId] = value
248
+ }
249
+ })()
250
+ }
251
+
252
+ async attach({ stream_id, cwd, model, effort, resumeSessionId, attachId }) {
253
+ if (this.closed) throw new Error('Grok bridge is shut down')
254
+ if (isUnderRemoval(cwd)) throw new Error(`worktree removal in progress: ${cwd}`)
255
+ // Re-run the lookup after a failed shared start: another waiter may already have registered a
256
+ // replacement, and every waiter must join it rather than start its own process. Lookup and the
257
+ // cold registration below run synchronously after the last await, so no gap opens between them.
258
+ const failed = new Set()
259
+ for (;;) {
260
+ if (this.closed) throw new Error('Grok bridge is shut down')
261
+ const live = this._liveSessions().filter((s) => !failed.has(s))
262
+ const existing = (attachId && live.find((s) => s.attachId === attachId)) ||
263
+ (resumeSessionId && live.find((s) => s.cwd === cwd && (s.sessionId === resumeSessionId ||
264
+ (s.sessionId === null && s.resumeSessionId === resumeSessionId))))
265
+ if (!existing) break
266
+ const sessionId = await existing.ready
267
+ if (!sessionId || existing.dead) { failed.add(existing); continue }
268
+ if (this.closed) throw new Error('Grok bridge is shut down')
269
+ const occupied = this.sessions.get(stream_id)
270
+ if (occupied && occupied !== existing) throw new Error('stream already attached')
271
+ if (existing.dead || existing.client.closed) throw new Error('Grok session detached')
272
+ existing.streamIds.add(stream_id)
273
+ if (!existing.lastActivityByStream.has(stream_id)) existing.lastActivityByStream.set(stream_id, Date.now())
274
+ this.sessions.set(stream_id, existing)
275
+ existing.stream_id = stream_id
276
+ existing.detachRequested = false
277
+ existing.browserDetachedAtMs = null
278
+ existing.explicitBrowserDetachPendingAttach = false
279
+ this._touch(existing, stream_id)
280
+ for (const [token, entry] of existing.pendingPermissions) {
281
+ this.emit('grok_permission', { stream_id: existing.stream_id, request_id: token, params: entry.params })
282
+ }
283
+ return this._snapshot(existing, true)
284
+ }
285
+ if (this.sessions.has(stream_id)) throw new Error('stream already attached')
286
+ // The loop above may have awaited a shared start; removal can begin meanwhile, so check again
287
+ // right before spawning (same as the Codex bridge).
288
+ if (isUnderRemoval(cwd)) throw new Error(`worktree removal in progress: ${cwd}`)
289
+ const now = Date.now()
290
+ let resolveReady
291
+ const session = { seq: ++this.sessionSeq, stream_id, cwd, attachId, resumeSessionId,
292
+ streamIds: new Set([stream_id]), lastActivityByStream: new Map([[stream_id, now]]),
293
+ ready: new Promise((resolve) => { resolveReady = resolve }), readyDone: false, dead: false,
294
+ detachRequested: false, explicitBrowserDetachPendingAttach: false, browserDetachedAtMs: null,
295
+ lastActivityMs: now, lastTurnAtMs: now, promptInFlight: false,
296
+ inputQueue: [], dispatchingInput: null, queueStateSeq: 0,
297
+ acceptedInputIds: [], cancelledInputIds: [], canceledBeforeArrival: new Set(),
298
+ pendingPermissions: new Map(), turnSeq: 0, cancelledTurn: null, acceptedRequestIds: new Map(),
299
+ sessionId: null, updateSeq: 0, historyItems: [], historyItemBytes: [], historyBytes: 2, historyEpoch: 0, historyMergeEpoch: -1,
300
+ historyTruncated: false }
301
+ const client = session.client = this._client(cwd, session)
302
+ this.sessions.set(stream_id, session)
303
+ try {
304
+ await this._initialize(client)
305
+ const result = await client.request(resumeSessionId ? 'session/load' : 'session/new', {
306
+ cwd, mcpServers: [], ...(resumeSessionId ? { sessionId: resumeSessionId } : {}),
307
+ })
308
+ session.sessionId = resumeSessionId || result.sessionId
309
+ if (!session.sessionId) throw new Error('ACP response has no sessionId')
310
+ await this._applyConfig(session, { model, effort })
311
+ if (this.sessions.get(stream_id) !== session || client.closed) throw new Error('Grok session detached')
312
+ session.readyDone = true
313
+ session.historyEpoch++
314
+ resolveReady(session.sessionId)
315
+ this._reconcileWarmBounds(Date.now(), 'attach')
316
+ return this._snapshot(session, false)
317
+ } catch (error) {
318
+ session.readyDone = true
319
+ resolveReady(null)
320
+ this._forget(session)
321
+ // Not emitted here: the caller reports the thrown error once, tagged phase=attach.
322
+ await this._dispose(client)
323
+ throw error
324
+ }
325
+ }
326
+
327
+ _require(stream_id) {
328
+ const session = this.sessions.get(stream_id)
329
+ if (!session || session.dead || !session.readyDone || session.client.closed) throw new Error('Grok session is not ready')
330
+ return session
331
+ }
332
+
333
+ input({ stream_id, message, inputId, model, effort }) {
334
+ const session = this._require(stream_id)
335
+ const prompt = promptFromMessage(message)
336
+ if (!prompt.length) throw new Error('Grok prompt is empty')
337
+ this._touch(session, stream_id)
338
+ return { turn: this._enqueue(session, prompt, inputId, { model, effort }) }
339
+ }
340
+
341
+ _enqueue(session, prompt, inputId, config = {}) {
342
+ const id = typeof inputId === 'string' ? inputId : null
343
+ if (session.canceledBeforeArrival.has(id)) {
344
+ session.canceledBeforeArrival.delete(id)
345
+ this._recordInputId(session.cancelledInputIds, id)
346
+ this._emitQueueState(session)
347
+ return Promise.resolve(null)
348
+ }
349
+ const turn = new Promise((resolve, reject) => {
350
+ session.inputQueue.push({ inputId: id, text: prompt.map((block) => block.text).join('\n'),
351
+ prompt, config, resolve, reject })
352
+ })
353
+ // 完了を待たない注入の失敗も通知する。待機取消は正常な応答で扱う。
354
+ turn.catch((error) => {
355
+ if (error.reason === 'cancelled') return
356
+ this.emit('grok_error', { stream_id: session.stream_id, session_id: session.sessionId,
357
+ inputId: id, message: error.message, lastSeq: session.updateSeq })
358
+ })
359
+ this._pump(session)
360
+ this._emitQueueState(session)
361
+ return turn
362
+ }
363
+
364
+ _recordInputId(ids, id) {
365
+ if (!id) return
366
+ ids.push(id)
367
+ if (ids.length > 256) ids.splice(0, ids.length - 256)
368
+ }
369
+
370
+ _pump(session) {
371
+ if (session.dead || session.promptInFlight || session.dispatchingInput || !session.inputQueue.length) return
372
+ const item = session.inputQueue.shift()
373
+ session.dispatchingInput = item
374
+ const { prompt, inputId } = item
375
+ const run = async () => {
376
+ session.promptInFlight = true
377
+ session.historyEpoch++
378
+ const turn = ++session.turnSeq
379
+ try {
380
+ const configuring = this._applyConfig(session, item.config)
381
+ if (configuring) await configuring
382
+ if (session.dead || session.client.closed) throw new Error('Grok session detached')
383
+ if (session.cancelledTurn === turn) {
384
+ this._recordInputId(session.cancelledInputIds, inputId)
385
+ this._emitQueueState(session)
386
+ const result = { stopReason: 'cancelled' }
387
+ this.emit('grok_turn_complete', { stream_id: session.stream_id, session_id: session.sessionId,
388
+ stopReason: result.stopReason, lastSeq: session.updateSeq })
389
+ return result
390
+ }
391
+ this._recordInputId(session.acceptedInputIds, inputId)
392
+ this._emitQueueState(session, item)
393
+ for (const block of prompt) {
394
+ const update = { sessionUpdate: 'user_message_chunk',
395
+ content: { type: 'text', text: block.text },
396
+ ...(inputId != null ? { _meta: { 'cockpit/input_id': inputId } } : {}) }
397
+ this._recordUpdate(session, update)
398
+ if (session.readyDone) this.emit('grok_event', { stream_id: session.stream_id,
399
+ session_id: session.sessionId, seq: session.updateSeq, update })
400
+ }
401
+ // ACP prompt resolves only at turn completion, including time spent awaiting permission.
402
+ const result = await session.client.request('session/prompt', {
403
+ sessionId: session.sessionId, prompt,
404
+ }, { timeoutMs: 0 })
405
+ // lastSeq lets the browser detect a dropped tail (chunks are not buffered while the Hub link
406
+ // is down) even when no further update follows; it then re-attaches to read the history.
407
+ this.emit('grok_turn_complete', { stream_id: session.stream_id, session_id: session.sessionId, stopReason: result.stopReason, lastSeq: session.updateSeq })
408
+ return result
409
+ } finally {
410
+ session.promptInFlight = false
411
+ session.historyEpoch++
412
+ session.lastTurnAtMs = Date.now()
413
+ this._clearTurnPermissions(session, turn)
414
+ }
415
+ }
416
+ run().then(item.resolve, item.reject).finally(() => {
417
+ session.dispatchingInput = null
418
+ this._pump(session)
419
+ })
420
+ }
421
+
422
+ queueState(stream_id) {
423
+ return this._queueStateOf(this._require(stream_id))
424
+ }
425
+
426
+ // 内部の通知・ready は session を直接渡す。_require を通すと、通信が閉じた直後に
427
+ // 積んだ後・取消応答の後で例外になり、受理済みの入力や取消の通知が途中で止まる。
428
+ _queueStateOf(session) {
429
+ const pending = session.inputQueue
430
+ return { type: 'queue_state', pending: pending.length,
431
+ messages: pending.map((item) => item.text),
432
+ items: pending.map((item) => ({ id: item.inputId || '', text: item.text })),
433
+ seq: session.queueStateSeq, generation: session.seq,
434
+ accepted: [...session.acceptedInputIds], cancelled: [...session.cancelledInputIds] }
435
+ }
436
+
437
+ _emitQueueState(session, startedItem = null) {
438
+ if (session.dead) return
439
+ session.queueStateSeq++
440
+ const snapshot = this._queueStateOf(session)
441
+ snapshot.started = startedItem ? [startedItem.text] : []
442
+ snapshot.startedIds = startedItem ? [startedItem.inputId] : []
443
+ this.emit('grok_queue_state', { stream_id: session.stream_id,
444
+ session_id: session.sessionId, queue_state: snapshot })
445
+ }
446
+
447
+ cancelQueued({ stream_id, id }) {
448
+ const session = this.sessions.get(stream_id)
449
+ if (!session || session.dead) return { outcome: 'stream_missing', flush: () => {} }
450
+ if (typeof id !== 'string' || !id) throw new Error('invalid_input_id')
451
+ // 取消の応答を先に送れるよう、状態通知は呼び出し側の flush に委ねる。
452
+ const flush = () => this._emitQueueState(session)
453
+ const index = session.inputQueue.findIndex((item) => item.inputId === id)
454
+ if (index >= 0) {
455
+ const [item] = session.inputQueue.splice(index, 1)
456
+ this._recordInputId(session.cancelledInputIds, id)
457
+ item.reject(Object.assign(new Error('Grok queued input cancelled'), { reason: 'cancelled' }))
458
+ return { outcome: 'cancelled', flush }
459
+ }
460
+ if (session.cancelledInputIds.includes(id)) return { outcome: 'cancelled', flush }
461
+ // 送出済みの項目はキュー取消では止められない(実行中の停止は interrupt)。
462
+ if (session.dispatchingInput?.inputId === id || session.acceptedInputIds.includes(id)) {
463
+ return { outcome: 'already_started', flush }
464
+ }
465
+ // 通信順序で取消が入力より先に届いても、後着の入力を実行しない。
466
+ if (session.canceledBeforeArrival.size >= 1024) session.canceledBeforeArrival.clear()
467
+ session.canceledBeforeArrival.add(id)
468
+ return { outcome: 'cancelled', flush }
469
+ }
470
+
471
+ _clearTurnPermissions(session, turn, cancel = false) {
472
+ const requestIds = []
473
+ for (const [token, entry] of session.pendingPermissions) {
474
+ if (entry.turn !== turn) continue
475
+ session.pendingPermissions.delete(token)
476
+ requestIds.push(token)
477
+ if (cancel) session.client.respond(entry.rpcId, { outcome: { outcome: 'cancelled' } })
478
+ }
479
+ // browser は承認カードの生死を推測できない(中断の送信と応答の間に届いた承認など)ため、
480
+ // 破棄した承認を確定情報として知らせる。
481
+ if (requestIds.length) {
482
+ this.emit('grok_permission_cancelled', { stream_id: session.stream_id, session_id: session.sessionId, request_ids: requestIds })
483
+ }
484
+ }
485
+
486
+ interrupt({ stream_id }) {
487
+ const session = this._require(stream_id)
488
+ this._touch(session, stream_id)
489
+ session.cancelledTurn = session.turnSeq
490
+ this._clearTurnPermissions(session, session.turnSeq, true)
491
+ session.client.notify('session/cancel', { sessionId: session.sessionId })
492
+ }
493
+
494
+ permissionReply({ stream_id, request_id, result }) {
495
+ const session = this.sessions.get(stream_id)
496
+ const entry = session?.pendingPermissions.get(request_id)
497
+ if (!entry || session.dead || !session.streamIds.has(stream_id) || session.client.closed ||
498
+ entry.turn !== session.turnSeq || entry.turn === session.cancelledTurn) return
499
+ this._touch(session, stream_id)
500
+ session.pendingPermissions.delete(request_id)
501
+ session.client.respond(entry.rpcId, result)
502
+ }
503
+
504
+ detach({ stream_id }) {
505
+ const session = this.sessions.get(stream_id)
506
+ if (!session || session.dead) return
507
+ if (session.streamIds.size > 1) {
508
+ this._dropStream(session, stream_id)
509
+ return
510
+ }
511
+ session.detachRequested = true
512
+ session.explicitBrowserDetachPendingAttach = true
513
+ session.browserDetachedAtMs ??= Date.now()
514
+ }
515
+
516
+ _dropStream(session, stream_id) {
517
+ session.streamIds.delete(stream_id)
518
+ session.lastActivityByStream.delete(stream_id)
519
+ if (this.sessions.get(stream_id) === session) this.sessions.delete(stream_id)
520
+ if (session.stream_id === stream_id) session.stream_id = Array.from(session.streamIds).at(-1)
521
+ }
522
+
523
+ keepalive({ stream_id }) {
524
+ const session = this.sessions.get(stream_id)
525
+ if (!session || session.dead || session.explicitBrowserDetachPendingAttach) return false
526
+ session.detachRequested = false
527
+ session.browserDetachedAtMs = null
528
+ this._touch(session, stream_id)
529
+ return true
530
+ }
531
+
532
+ gcDeadTerminals(ttlMs = this._deadTerminalTtlMs) {
533
+ const now = Date.now()
534
+ let removed = 0
535
+ for (const session of this._liveSessions()) {
536
+ for (const sid of Array.from(session.streamIds)) {
537
+ if (session.streamIds.size <= 1) break
538
+ if (now - (session.lastActivityByStream.get(sid) ?? 0) < ttlMs) continue
539
+ this._dropStream(session, sid)
540
+ removed++
541
+ }
542
+ if (session.streamIds.size === 1) {
543
+ const [sid] = session.streamIds
544
+ if (now - (session.lastActivityByStream.get(sid) ?? 0) >= ttlMs) {
545
+ session.detachRequested = true
546
+ session.browserDetachedAtMs ??= now
547
+ }
548
+ }
549
+ }
550
+ return removed
551
+ }
552
+
553
+ _isProtected(session, now) {
554
+ if (!session.readyDone && !session.dead) return true
555
+ // A stuck running turn must not stay protected just because inputs are queued behind it
556
+ // (they cannot start until it completes); reaping rejects both the turn and the queue.
557
+ if (session.promptInFlight) return now - session.lastActivityMs <= this._stuckTurnTtlMs
558
+ return session.inputQueue.length > 0
559
+ }
560
+
561
+ hasActiveWork(now = Date.now()) {
562
+ return this._liveSessions().some((session) => this._isProtected(session, now))
563
+ }
564
+
565
+ listWarmCandidates(now = Date.now()) {
566
+ return this._liveSessions().map((session) => ({ kind: 'grok', cwd: session.cwd,
567
+ recency: session.lastActivityMs, seq: session.seq, protected: this._isProtected(session, now),
568
+ reap: (reason) => this._reapSession(session, now, reason) }))
569
+ }
570
+
571
+ _reapSession(session, now, reason) {
572
+ if (session.dead) return
573
+ this._forget(session)
574
+ void this._dispose(session.client)
575
+ this.emit('grok_exit', { cwd: session.cwd, stream_id: session.stream_id, session_id: session.sessionId,
576
+ reason: ['session-deleted', 'chat-stop', 'chat-stop-all', 'worktree-remove'].includes(reason) ? reason : 'idle-reaped',
577
+ lastSeq: session.updateSeq })
578
+ }
579
+
580
+ _reconcileWarmBounds(now, reason = 'warm-bounds') {
581
+ let evicted = 0
582
+ const byCwd = new Map()
583
+ for (const session of this._liveSessions()) {
584
+ if (!byCwd.has(session.cwd)) byCwd.set(session.cwd, [])
585
+ byCwd.get(session.cwd).push(session)
586
+ }
587
+ for (const sessions of byCwd.values()) {
588
+ sessions.sort((a, b) => b.lastActivityMs - a.lastActivityMs || b.seq - a.seq)
589
+ for (const session of sessions.slice(this._warmPerCwdMax)) {
590
+ if (this._isProtected(session, now)) continue
591
+ this._reapSession(session, now, `${reason}:per-cwd`)
592
+ evicted++
593
+ }
594
+ }
595
+ const cwdInfo = new Map()
596
+ for (const session of this._liveSessions()) {
597
+ if (!cwdInfo.has(session.cwd)) cwdInfo.set(session.cwd, { recency: -Infinity, seq: -Infinity, protected: false, sessions: [] })
598
+ const info = cwdInfo.get(session.cwd)
599
+ info.recency = Math.max(info.recency, session.lastActivityMs)
600
+ info.seq = Math.max(info.seq, session.seq)
601
+ info.protected ||= this._isProtected(session, now)
602
+ info.sessions.push(session)
603
+ }
604
+ const overflow = cwdInfo.size - this._warmCwdMax
605
+ const evictable = [...cwdInfo.values()].filter((info) => !info.protected)
606
+ .sort((a, b) => a.recency - b.recency || a.seq - b.seq)
607
+ for (const info of evictable.slice(0, Math.max(0, overflow))) {
608
+ for (const session of info.sessions) {
609
+ this._reapSession(session, now, `${reason}:cwd-lru`)
610
+ evicted++
611
+ }
612
+ }
613
+ return evicted
614
+ }
615
+
616
+ reconcileSessions() {
617
+ const now = Date.now()
618
+ let reaped = 0
619
+ this.gcDeadTerminals()
620
+ for (const session of this._liveSessions()) {
621
+ if (this._isProtected(session, now)) continue
622
+ const idle = now - session.lastActivityMs
623
+ let reason
624
+ if (session.promptInFlight && idle > this._stuckTurnTtlMs) reason = 'stuck-turn'
625
+ else if (session.browserDetachedAtMs !== null && now - session.browserDetachedAtMs > this._detachedReapTtlMs) reason = 'browser-detached-ttl'
626
+ else if (!session.promptInFlight && idle > this._detachTtlMs) reason = 'idle-ttl'
627
+ if (reason) { this._reapSession(session, now, reason); reaped++ }
628
+ }
629
+ return reaped + this._reconcileWarmBounds(now, 'warm-bounds')
630
+ }
631
+
632
+ gcAbandonedSessions() { return this.reconcileSessions() }
633
+
634
+ async stopSessionForStream({ stream_id, reason }) {
635
+ const session = this.sessions.get(stream_id)
636
+ if (!session || session.dead) return 0
637
+ this._reapSession(session, Date.now(), reason)
638
+ await this._dispose(session.client)
639
+ return 1
640
+ }
641
+
642
+ async stopSessionsForCwd({ cwd, reason }) {
643
+ const sessions = this._liveSessions().filter((session) => session.cwd === cwd)
644
+ await Promise.all(sessions.map(({ stream_id }) => this.stopSessionForStream({ stream_id, reason })))
645
+ return sessions.length
646
+ }
647
+
648
+ /**
649
+ * Waits until every client is reaped or given up on. `unreaped` counts every client whose
650
+ * process group could not be confirmed gone (including earlier detach/exit failures); it is
651
+ * not a live count, and those groups are never re-signalled.
652
+ */
653
+ async shutdown() {
654
+ this.closed = true
655
+ for (const session of this._liveSessions()) this._reapSession(session, Date.now(), 'shutdown')
656
+ await Promise.all([...this.clients].map((client) => this._dispose(client)))
657
+ return { unreaped: this.unreaped.size }
658
+ }
659
+
660
+ listThreads({ cwd, limit } = {}) {
661
+ const key = JSON.stringify([cwd ?? null, limit ?? null])
662
+ if (this.listRequests.has(key)) return this.listRequests.get(key)
663
+ const request = this._listThreads({ cwd, limit })
664
+ this.listRequests.set(key, request)
665
+ const clear = () => { this.listRequests.delete(key) }
666
+ request.then(clear, clear)
667
+ return request
668
+ }
669
+
670
+ async _listThreads({ cwd, limit }) {
671
+ const client = this._client(cwd)
672
+ try {
673
+ const init = await this._initialize(client)
674
+ if (!init.agentCapabilities?.sessionCapabilities?.list) return []
675
+ const sessions = []
676
+ const cursors = new Set()
677
+ let cursor
678
+ do {
679
+ const result = await client.request('session/list', { ...(cwd ? { cwd } : {}), ...(cursor ? { cursor } : {}) })
680
+ sessions.push(...result.sessions)
681
+ cursor = result.nextCursor
682
+ if (cursor && cursors.has(cursor)) throw new Error('Repeated ACP session/list cursor')
683
+ cursors.add(cursor)
684
+ } while (cursor && (limit == null || sessions.length < limit))
685
+ return limit == null ? sessions : sessions.slice(0, Math.max(0, limit))
686
+ } catch (error) {
687
+ if (error.code === -32601) return []
688
+ this.emit('grok_error', { message: error.message })
689
+ throw error
690
+ } finally { await this._dispose(client) }
691
+ }
692
+
693
+ _readySessions() { return this._liveSessions().filter((s) => s.readyDone && !s.client.closed) }
694
+ getThreadId(stream_id) { return this._readySessions().find((s) => s.streamIds.has(stream_id))?.sessionId ?? null }
695
+ getCwd(stream_id) { return this._readySessions().find((s) => s.streamIds.has(stream_id))?.cwd ?? null }
696
+ liveSessionForCwd(cwd) { return this._readySessions().find((s) => s.cwd === cwd) ?? null }
697
+ _describe(session) {
698
+ return { kind: 'grok', cwd: session.cwd, stream_id: session.stream_id, thread_id: session.sessionId,
699
+ busy: session.promptInFlight, detached: session.detachRequested,
700
+ idle_ms: Date.now() - (session.lastTurnAtMs ?? session.lastActivityMs) }
701
+ }
702
+ describeLiveSessions(cwd) {
703
+ return this._liveSessions().filter((s) => !cwd || s.cwd === cwd).map((s) => this._describe(s))
704
+ }
705
+ async sendToCwd(cwd, text, source, requestId) {
706
+ const live = this._readySessions().filter((s) => s.cwd === cwd)
707
+ // Attached (not detach-requested) sessions take precedence, as in codex sendToCwd: a warm
708
+ // session the browser left must not make the only attached one ambiguous.
709
+ const attached = live.filter((s) => !s.detachRequested)
710
+ const candidates = attached.length ? attached : live
711
+ if (!candidates.length) return { status: 'not_found' }
712
+ if (candidates.length > 1) return { status: 'ambiguous', candidates: candidates.map((session) => this._describe(session)) }
713
+ return this._deliverInjection(candidates[0], text, source, requestId)
714
+ }
715
+
716
+ async sendToStream(cwd, id, text, source, requestId) {
717
+ if (!cwd || !id || !text) return { status: 'not_found' }
718
+ const live = this._readySessions().filter((s) => s.cwd === cwd)
719
+ const byStream = live.find((s) => s.streamIds.has(id))
720
+ const candidates = byStream ? [byStream] : live.filter((s) => s.sessionId === id)
721
+ if (!candidates.length) return { status: 'not_found' }
722
+ if (candidates.length > 1) return { status: 'ambiguous', candidates: candidates.map((s) => this._describe(s)) }
723
+ return this._deliverInjection(candidates[0], text, source, requestId)
724
+ }
725
+
726
+ async _deliverInjection(session, text, source, requestId) {
727
+ const ids = session.acceptedRequestIds
728
+ if (requestId != null && ids.has(requestId)) return { status: 'duplicate' }
729
+ if (requestId != null && [...ids.values()].filter((state) => state === 'pending').length >= MAX_PENDING_DELIVERIES) {
730
+ return { status: 'busy' }
731
+ }
732
+ const prompt = promptFromMessage({ content: text })
733
+ if (!prompt.length) throw new Error('Grok prompt is empty')
734
+ // As in codex sendToCwd: an accepted delivery makes the session the active routing target
735
+ // (detachRequested=false) while browser absence stays tracked by browserDetachedAtMs.
736
+ // explicitBrowserDetachPendingAttach is left as is, so stale keepalives stay rejected.
737
+ session.detachRequested = false
738
+ this._touch(session)
739
+ if (session.browserDetachedAtMs !== null) session.browserDetachedAtMs = Date.now()
740
+ const turn = this._enqueue(session, prompt, requestId)
741
+ if (requestId != null) {
742
+ ids.set(requestId, 'pending')
743
+ const settle = () => {
744
+ if (ids.get(requestId) !== 'pending') return
745
+ ids.set(requestId, 'done')
746
+ let done = [...ids.values()].filter((state) => state === 'done').length
747
+ for (const [id, state] of ids) {
748
+ if (done <= MAX_ACCEPTED_REQUEST_IDS) break
749
+ if (state === 'done') { ids.delete(id); done-- }
750
+ }
751
+ }
752
+ turn.then(settle, settle)
753
+ }
754
+ return { status: 'delivered', session: { stream_id: session.stream_id, sessionId: session.sessionId } }
755
+ }
756
+ }