* gRPC Server
* gRPC fix
* UpdProto
* fix: address PR review feedback for gRPC server
- Update bun.lock for new dependencies (frozen-lockfile CI fix)
- Add multi-turn session persistence via initialMessages
- Replace hardcoded done payload with real token counts
- Default bind to localhost instead of 0.0.0.0
* fix(grpc): startup parity, cancel interrupt, and cli text fallback
- Replace enableConfigs() with await init() in start-grpc.ts for full
bootstrap parity with the main CLI (env vars, CA certs, mTLS, proxy,
OAuth, Windows shell)
- Call engine.interrupt() before call.end() in the cancel handler so
in-flight model/tool execution is actually stopped
- Show done.full_text in the CLI client when no text_chunk was received,
preventing silent drops when streaming is unavailable
* fix(grpc): wire session_id end-to-end and remove dead provider field
- Move session_id from ClientMessage into ChatRequest to fix proto-loader
oneofs encoding bug and make the field functional
- Implement in-memory session store so reconnecting with the same
session_id resumes conversation context across streams
- Remove ChatRequest.provider — per-request provider routing requires
global process.env mutation, unsafe for concurrent clients; provider
is configured via env vars at server startup
* fix(grpc): mirror CLI auth bootstrap in start-grpc and fix tool_name field
scripts/start-grpc.ts now runs the same provider/auth bootstrap as the
normal CLI entrypoint: enableConfigs, safe env vars, Gemini/GitHub token
hydration, saved-profile resolution with warn-and-fallback, and provider
validation before the server binds.
ToolCallResult.tool_name was being populated with the tool_use_id UUID.
Added a toolNameById map (filled in canUseTool) so tool_name now carries
the actual tool name (e.g. "Bash"). The UUID moves to a new tool_use_id
field (proto field 4) for client-side correlation.
* fix(grpc): add tool_use_id to ToolCallStart and interrupt engine on stream close
Two blocker-level issues flagged in code review:
- ToolCallStart was missing tool_use_id, making it impossible for clients
to correlate tool_start events with tool_result when the same tool runs
multiple times. Added tool_use_id = 3 to the proto message and populated
it from the toolUseID parameter in canUseTool.
- On stream close without an explicit CancelSignal the server only nulled
the engine reference, leaving the underlying model/tool work running
as an orphan. Added engine.interrupt() in the call.on('end') handler
to stop work immediately when the client disconnects.
* fix(grpc): resolve pending promises on disconnect and guard post-cancel writes
Four lifecycle and contract issues identified during proactive review:
- Pending permission Promises in canUseTool would hang forever if the
client disconnected mid-stream. On call 'end', all pending resolvers
are now called with 'no' so the engine can unblock and terminate.
- The done message and session save could fire after call.end() when
a CancelSignal arrived mid-generation. Added an `interrupted` flag
set on both cancel and stream close to gate all post-loop writes.
- The session map had no eviction policy, allowing unbounded memory
growth. Capped at MAX_SESSIONS=1000 with FIFO eviction of the
oldest entry.
- Field 3 was silently absent from ChatRequest. Added `reserved 3`
to document the gap and prevent accidental reuse in future.
* fix(grpc): reset previousMessages on each new request to prevent session history leak
previousMessages was declared at stream scope and only overwritten when
the incoming session_id already existed in the session store. A second
request on the same stream with a new session_id would silently inherit
the first request's conversation history in initialMessages instead of
starting fresh, violating the session contract.
Fix: reset previousMessages to [] at the start of each ChatRequest
before the session-store lookup.
* fix(grpc): reset interrupted flag between requests and guard against concurrent ChatRequest
Two stream-scoped state bugs found during proactive audit:
- The `interrupted` flag was never reset between requests on the same
stream. If the first request was cancelled, all subsequent requests
would silently skip the done message, causing the client to hang.
- A second ChatRequest arriving while the first was still processing
would overwrite the engine reference, corrupting the lifecycle of
both requests. Now returns ALREADY_EXISTS error instead. Engine is
nulled after the for-await loop completes so subsequent requests
can proceed normally.
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
122 lines
3.6 KiB
TypeScript
122 lines
3.6 KiB
TypeScript
import * as grpc from '@grpc/grpc-js'
|
|
import * as protoLoader from '@grpc/proto-loader'
|
|
import path from 'path'
|
|
import * as readline from 'readline'
|
|
|
|
const PROTO_PATH = path.resolve(import.meta.dirname, '../src/proto/openclaude.proto')
|
|
|
|
const packageDefinition = protoLoader.loadSync(PROTO_PATH, {
|
|
keepCase: true,
|
|
longs: String,
|
|
enums: String,
|
|
defaults: true,
|
|
oneofs: true,
|
|
})
|
|
|
|
const protoDescriptor = grpc.loadPackageDefinition(packageDefinition) as any
|
|
const openclaudeProto = protoDescriptor.openclaude.v1
|
|
|
|
const rl = readline.createInterface({
|
|
input: process.stdin,
|
|
output: process.stdout
|
|
})
|
|
|
|
function askQuestion(query: string): Promise<string> {
|
|
return new Promise(resolve => {
|
|
rl.question(query, resolve)
|
|
})
|
|
}
|
|
|
|
async function main() {
|
|
const host = process.env.GRPC_HOST || 'localhost'
|
|
const port = process.env.GRPC_PORT || '50051'
|
|
const client = new openclaudeProto.AgentService(
|
|
`${host}:${port}`,
|
|
grpc.credentials.createInsecure()
|
|
)
|
|
|
|
let call: grpc.ClientDuplexStream<any, any> | null = null
|
|
|
|
const startStream = () => {
|
|
call = client.Chat()
|
|
let textStreamed = false
|
|
|
|
call.on('data', async (serverMessage: any) => {
|
|
if (serverMessage.text_chunk) {
|
|
process.stdout.write(serverMessage.text_chunk.text)
|
|
textStreamed = true
|
|
} else if (serverMessage.tool_start) {
|
|
console.log(`\n\x1b[36m[Tool Call]\x1b[0m \x1b[1m${serverMessage.tool_start.tool_name}\x1b[0m`)
|
|
console.log(`\x1b[90m${serverMessage.tool_start.arguments_json}\x1b[0m\n`)
|
|
} else if (serverMessage.tool_result) {
|
|
console.log(`\n\x1b[32m[Tool Result]\x1b[0m \x1b[1m${serverMessage.tool_result.tool_name}\x1b[0m`)
|
|
const out = serverMessage.tool_result.output
|
|
if (out.length > 500) {
|
|
console.log(`\x1b[90m${out.substring(0, 500)}...\n(Output truncated, total length: ${out.length})\x1b[0m`)
|
|
} else {
|
|
console.log(`\x1b[90m${out}\x1b[0m`)
|
|
}
|
|
} else if (serverMessage.action_required) {
|
|
const action = serverMessage.action_required
|
|
console.log(`\n\x1b[33m[Action Required]\x1b[0m`)
|
|
const reply = await askQuestion(`\x1b[1m${action.question}\x1b[0m (y/n) > `)
|
|
|
|
call?.write({
|
|
input: {
|
|
prompt_id: action.prompt_id,
|
|
reply: reply.trim()
|
|
}
|
|
})
|
|
} else if (serverMessage.done) {
|
|
if (!textStreamed && serverMessage.done.full_text) {
|
|
process.stdout.write(serverMessage.done.full_text)
|
|
}
|
|
textStreamed = false
|
|
console.log('\n\x1b[32m[Generation Complete]\x1b[0m')
|
|
promptUser()
|
|
} else if (serverMessage.error) {
|
|
console.error(`\n\x1b[31m[Server Error]\x1b[0m ${serverMessage.error.message}`)
|
|
promptUser()
|
|
}
|
|
})
|
|
|
|
call.on('end', () => {
|
|
console.log('\n\x1b[90m[Stream closed by server]\x1b[0m')
|
|
// Don't prompt user here, let 'done' or 'error' handlers do it
|
|
})
|
|
|
|
call.on('error', (err: Error) => {
|
|
console.error('\n\x1b[31m[Stream Error]\x1b[0m', err.message)
|
|
promptUser()
|
|
})
|
|
}
|
|
|
|
const promptUser = async () => {
|
|
const message = await askQuestion('\n\x1b[35m> \x1b[0m')
|
|
|
|
if (message.trim().toLowerCase() === '/exit' || message.trim().toLowerCase() === '/quit') {
|
|
console.log('Bye!')
|
|
rl.close()
|
|
process.exit(0)
|
|
}
|
|
|
|
if (!call || call.destroyed) {
|
|
startStream()
|
|
}
|
|
|
|
call!.write({
|
|
request: {
|
|
session_id: 'cli-session-1',
|
|
message: message,
|
|
working_directory: process.cwd()
|
|
}
|
|
})
|
|
}
|
|
|
|
console.log('\x1b[32mOpenClaude gRPC CLI\x1b[0m')
|
|
console.log('\x1b[90mType /exit to quit.\x1b[0m')
|
|
promptUser()
|
|
}
|
|
|
|
main()
|