feat(codex): share sessions across terminal and web

Use one native app-server for terminal, Web and phone clients while retaining the existing CLI and Runner lifecycle.

Synchronize native queues, permissions, question history and steering state; preserve explicit permission precedence and per-turn usage models. Resume inactive clear commands through Runner and reject independent child cold resumes.

Add shared-runtime regression tests, generated protocol fixtures and lifecycle documentation.
This commit is contained in:
weishu
2026-09-12 10:59:17 +08:00
parent 25af3f8e13
commit 0c4abcb3d1
111 changed files with 4684 additions and 960 deletions
+20
View File
@@ -447,6 +447,26 @@ describe('ApiSessionClient agy transcript messages', () => {
})
describe('ApiSessionClient incoming user messages', () => {
it.each([true, false])('replays explicitly marked native queue input only for shared sessions (%s)', shared => {
socketHarness.sockets.length = 0
const client = new ApiSessionClient('token', createSession({
metadata: { path: '/tmp', host: 'test', capabilities: { concurrentClients: shared } }
}))
const socket = socketHarness.sockets[0]
const received = vi.fn()
client.onUserMessage(received)
const message = { id: 'native-queued', seq: 10, localId: 'native-client-id', content: {
role: 'user', content: { type: 'text', text: 'queued before exit' },
meta: { sentFrom: 'cli', isNativeQueuedMessage: true }
} }
socket.trigger('update', { body: { t: 'new-message', message } })
socket.trigger('update', { body: { t: 'new-message', message } })
expect(received).toHaveBeenCalledTimes(shared ? 1 : 0)
if (shared) expect(received).toHaveBeenCalledWith(expect.objectContaining({
content: { type: 'text', text: 'queued before exit' }
}), 'native-client-id')
client.close()
})
it('ignores CLI-originated transcript messages while advancing the incoming cursor', () => {
socketHarness.sockets.length = 0
const client = new ApiSessionClient('token', createSession({ namespace: 'default' }))
+40 -17
View File
@@ -1,3 +1,4 @@
type QueueCancelResult = boolean | 'in-flight' | 'indeterminate' | 'consumed'
import { EventEmitter } from 'node:events'
import { randomUUID } from 'node:crypto'
import { io, type Socket } from 'socket.io-client'
@@ -36,7 +37,7 @@ import type {
import { AgentStateSchema, CliMessagesResponseSchema, MetadataSchema, UserMessageSchema } from './types'
import { RpcHandlerManager } from './rpc/RpcHandlerManager'
import { registerCommonHandlers } from '../modules/common/registerCommonHandlers'
import { cleanupUploadDir } from '../modules/common/handlers/uploads'
import { cleanupUploadDir, preserveUploadDirOnExit } from '../modules/common/handlers/uploads'
import { TerminalManager } from '@/terminal/TerminalManager'
import { applyVersionedAck } from './versionedUpdate'
import { buildHubRequestHeaders, buildSocketIoExtraHeaderOptions } from './hubExtraHeaders'
@@ -231,6 +232,8 @@ function hasSameJsonValue(left: unknown, right: unknown): boolean {
}
export class ApiSessionClient extends EventEmitter {
private reconnectHandler: (() => void) | null = null
onReconnect(handler: (() => void) | null): void { this.reconnectHandler = handler }
private readonly token: string
readonly sessionId: string
private metadata: Metadata | null
@@ -241,8 +244,8 @@ export class ApiSessionClient extends EventEmitter {
private pendingMessages: { message: UserMessage; localId?: string }[] = []
private pendingHubPromptEchoes: { text: string; localIds: string[] }[] = []
private pendingMessageCallback: ((message: UserMessage, localId?: string) => void) | null = null
private cancelQueuedMessageCallback: ((localId: string) => boolean | 'in-flight' | 'indeterminate' | 'consumed') | null = null
private retryQueuedMessageCallback: ((localId: string) => boolean) | null = null
private cancelQueuedMessageCallback: ((localId: string) => QueueCancelResult | Promise<QueueCancelResult>) | null = null
private retryQueuedMessageCallback: ((localId: string) => boolean | Promise<boolean>) | null = null
private readonly incomingFilter = new IncomingMessageFilter()
private backfillInFlight: Promise<void> | null = null
private needsBackfill = false
@@ -334,6 +337,7 @@ export class ApiSessionClient extends EventEmitter {
}
void this.backfillIfNeeded()
this.hasConnectedOnce = true
this.reconnectHandler?.()
this.socket.emit('session-alive', {
sid: this.sessionId,
time: Date.now(),
@@ -422,7 +426,7 @@ export class ApiSessionClient extends EventEmitter {
this.agentTerminalActive = false
}))
this.socket.on('update', (data: Update, ack?: (response: { removed: boolean; inFlight?: boolean; indeterminate?: boolean; accepted?: boolean; consumed?: boolean }) => void) => {
this.socket.on('update', async (data: Update, ack?: (response: { removed: boolean; inFlight?: boolean; indeterminate?: boolean; accepted?: boolean; consumed?: boolean }) => void) => {
try {
if (!data.body) return
@@ -436,10 +440,11 @@ export class ApiSessionClient extends EventEmitter {
// reservation and bypass the normal message-id dedup.
let accepted = true
if (data.body.localId && this.cancelQueuedMessageCallback) {
const cancelled = this.cancelQueuedMessageCallback(data.body.localId)
const cancellation = this.cancelQueuedMessageCallback(data.body.localId)
const cancelled = cancellation instanceof Promise ? await cancellation : cancellation
accepted = cancelled !== 'in-flight' && cancelled !== 'consumed'
if (cancelled === 'indeterminate') {
accepted = this.retryQueuedMessageCallback?.(data.body.localId) === true
accepted = await this.retryQueuedMessageCallback?.(data.body.localId) === true
}
if (cancelled === 'consumed') {
ack?.({ removed: false, accepted: false, consumed: true })
@@ -454,9 +459,10 @@ export class ApiSessionClient extends EventEmitter {
}
if (data.body.t === 'cancel-queued-message') {
const result = (data.body.localId && this.cancelQueuedMessageCallback)
const cancellation = (data.body.localId && this.cancelQueuedMessageCallback)
? this.cancelQueuedMessageCallback(data.body.localId)
: false
const result = cancellation instanceof Promise ? await cancellation : cancellation
// 'in-flight' = the row is inside an async steer: not
// removed, but also NOT consumed — the hub must neither
// delete it nor stamp invoked_at.
@@ -499,6 +505,11 @@ export class ApiSessionClient extends EventEmitter {
this.emit('message', data.body)
} catch (error) {
logger.debug('[SOCKET] [UPDATE] [ERROR] Error handling update', { error })
// A failed asynchronous native cancellation is not permission
// for the hub's best-effort path to delete a possibly live row.
if (data.body?.t === 'cancel-queued-message' || data.body?.t === 'retry-queued-message') {
ack?.({ removed: false, accepted: false, indeterminate: true })
}
}
})
@@ -696,11 +707,11 @@ export class ApiSessionClient extends EventEmitter {
}
}
onCancelQueuedMessage(callback: (localId: string) => boolean | 'in-flight' | 'indeterminate' | 'consumed'): void {
onCancelQueuedMessage(callback: (localId: string) => QueueCancelResult | Promise<QueueCancelResult>): void {
this.cancelQueuedMessageCallback = callback
}
onRetryQueuedMessage(callback: (localId: string) => boolean): void {
onRetryQueuedMessage(callback: (localId: string) => boolean | Promise<boolean>): void {
this.retryQueuedMessageCallback = callback
}
@@ -782,7 +793,10 @@ export class ApiSessionClient extends EventEmitter {
// User messages mirrored from a local agent transcript are history,
// not new remote input. Keep them in the incoming filter above so
// reconnect backfill still advances and deduplicates correctly.
if (userResult.data.meta?.sentFrom === 'cli') {
if (userResult.data.meta?.sentFrom === 'cli'
&& !(userResult.data.meta.isNativeQueuedMessage === true
&& this.metadata?.capabilities?.concurrentClients === true
&& message.localId)) {
return
}
this.enqueueUserMessage(userResult.data, message.localId ?? undefined)
@@ -1013,7 +1027,7 @@ export class ApiSessionClient extends EventEmitter {
})
}
sendUserMessage(text: string, meta?: MessageMeta): void {
sendUserMessage(text: string, meta?: MessageMeta, localId?: string): void {
if (!text) {
return
}
@@ -1033,17 +1047,23 @@ export class ApiSessionClient extends EventEmitter {
this.emitOrQueue(() => {
this.socket.emit('message', {
sid: this.sessionId,
message: content
message: content,
localId
})
if (localId) this.socket.emit('messages-consumed', { sid: this.sessionId, localIds: [localId] })
})
this.notifyUserActivity()
}
syncNativeQueuedMessage(localId: string, text: string | null): void {
this.emitOrQueue(() => this.socket.emit('native-queue-message', { sid: this.sessionId, localId, text }))
}
notifyUserActivity(): void {
void this.materialize()
}
sendAgentMessage(body: unknown): void {
sendAgentMessage(body: unknown, localId?: string): void {
const content = {
role: 'agent',
content: {
@@ -1057,8 +1077,10 @@ export class ApiSessionClient extends EventEmitter {
this.emitOrQueue(() => {
this.socket.emit('message', {
sid: this.sessionId,
message: content
message: content,
localId
})
if (localId) this.socket.emit('messages-consumed', { sid: this.sessionId, localIds: [localId] })
})
}
@@ -1180,7 +1202,7 @@ export class ApiSessionClient extends EventEmitter {
keepAlive(
thinking: boolean,
mode: 'local' | 'remote',
mode: 'local' | 'remote' | undefined,
runtime?: {
permissionMode?: SessionPermissionMode
model?: SessionModel
@@ -1264,9 +1286,10 @@ export class ApiSessionClient extends EventEmitter {
}))
}
sendSessionDeath(reason?: SessionEndReason): void {
sendSessionDeath(reason?: SessionEndReason, options?: { preserveUploads?: boolean }): void {
if (this.state === 'active') {
void cleanupUploadDir(this.sessionId)
if (options?.preserveUploads) preserveUploadDirOnExit(this.sessionId)
else void cleanupUploadDir(this.sessionId)
}
this.emitOrQueue(() => {
this.socket.emit('session-end', { sid: this.sessionId, time: Date.now(), reason })
+2
View File
@@ -71,6 +71,8 @@ export type {
export const MessageMetaSchema = z.object({
sentFrom: z.string().optional(),
// Shared Codex queue mirrors are replayable input, not transcript echoes.
isNativeQueuedMessage: z.boolean().optional(),
// Claude jsonl echoes the remote (web/telegram) prompt as a second user row.
// Hub notify ingest skips these so they do not consume a work_ad cause slot.
isTranscriptEcho: z.boolean().optional(),