85 lines
3.4 KiB
Python
85 lines
3.4 KiB
Python
"""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()
|