"""Small stdio client for the supported Codex App Server protocol.""" import json import queue import shutil import subprocess import threading import time class AppServer: def __init__(self, executable=None): executable = executable or shutil.which('codex') if not executable: raise RuntimeError('Codex unavailable on this executor') self.process = subprocess.Popen([executable,'app-server','--stdio'], stdin=subprocess.PIPE, stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,text=True,encoding='utf-8',errors='replace') self.incoming = queue.Queue() self.next_id = 0 self.notifications = [] def read(): for line in self.process.stdout: try: self.incoming.put(json.loads(line)) except ValueError: continue self.incoming.put(None) threading.Thread(target=read,daemon=True).start() self.call('initialize', {'clientInfo':{'name':'project_agent_control','version':'1.0'}, 'capabilities':{'experimentalApi':True}}) self.send({'method':'initialized','params':{}}) def send(self, value): self.process.stdin.write(json.dumps(value,ensure_ascii=True)+'\n') self.process.stdin.flush() def call(self, method, params, timeout=30): self.next_id += 1 request_id = self.next_id self.send({'id':request_id,'method':method,'params':params}) deadline=time.monotonic()+timeout while True: try: event=self.incoming.get(timeout=max(0.01,deadline-time.monotonic())) except queue.Empty: raise RuntimeError('App Server request timed out; outcome must be inspected') from None if event is None: raise RuntimeError('App Server stopped') if event.get('id') == request_id and 'method' not in event: if 'error' in event: # Surface protocol code only, never arbitrary environment output. raise RuntimeError('App Server rejected request: '+str(event['error'].get('code'))) return event['result'] if 'id' in event and 'method' in event: self.send({'id':event['id'],'error':{'code':-32601,'message':'Interactive approval must be handled by the human operator'}}) else: self.notifications.append(event) def history(self, thread_id): meta=self.call('thread/read',{'threadId':thread_id,'includeTurns':False})['thread'] cursor=None turns=[] while True: params={'threadId':thread_id,'limit':100,'sortDirection':'asc','itemsView':'full'} if cursor: params['cursor']=cursor result=self.call('thread/turns/list',params) turns.extend(result.get('data',[])) cursor=result.get('nextCursor') if not cursor: break self.notifications.clear() return meta,turns def close(self): if self.process.poll() is None: self.process.terminate() try: self.process.wait(timeout=10) except subprocess.TimeoutExpired: self.process.kill() self.process.wait(timeout=10) def __enter__(self): return self def __exit__(self,*args): self.close()