codex-mode 0.1.0a7__py3-none-any.whl
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.
- codex_mode/__init__.py +2 -0
- codex_mode/__main__.py +3 -0
- codex_mode/accounts.py +91 -0
- codex_mode/benchmark.py +99 -0
- codex_mode/cli.py +199 -0
- codex_mode/engine.py +487 -0
- codex_mode/onboarding.py +583 -0
- codex_mode-0.1.0a7.dist-info/METADATA +176 -0
- codex_mode-0.1.0a7.dist-info/RECORD +13 -0
- codex_mode-0.1.0a7.dist-info/WHEEL +5 -0
- codex_mode-0.1.0a7.dist-info/entry_points.txt +2 -0
- codex_mode-0.1.0a7.dist-info/licenses/LICENSE +21 -0
- codex_mode-0.1.0a7.dist-info/top_level.txt +1 -0
codex_mode/engine.py
ADDED
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
"""Switch Codex request routing while retaining one history and ChatGPT login."""
|
|
2
|
+
import base64
|
|
3
|
+
import socket
|
|
4
|
+
import struct
|
|
5
|
+
import contextlib
|
|
6
|
+
import datetime
|
|
7
|
+
import fcntl
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
import queue
|
|
13
|
+
import re
|
|
14
|
+
import shutil
|
|
15
|
+
import signal
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
import tempfile
|
|
19
|
+
import time
|
|
20
|
+
import tomllib
|
|
21
|
+
import tomlkit
|
|
22
|
+
from . import __version__
|
|
23
|
+
|
|
24
|
+
VERSION = __version__
|
|
25
|
+
VERBOSE = False
|
|
26
|
+
PAUSED_GOALS = []
|
|
27
|
+
|
|
28
|
+
def color(text,kind='info',stream=None):
|
|
29
|
+
stream=stream or sys.stdout
|
|
30
|
+
if stream.isatty() and 'NO_COLOR' not in os.environ and os.environ.get('TERM')!='dumb':
|
|
31
|
+
return '\033['+{'info':'36','ok':'32','warn':'33','error':'31'}[kind]+'m'+text+'\033[0m'
|
|
32
|
+
return text
|
|
33
|
+
|
|
34
|
+
def say(text,kind='info',detail=False):
|
|
35
|
+
if not detail or VERBOSE:print(color(text,kind),flush=True)
|
|
36
|
+
|
|
37
|
+
def error_text(error):
|
|
38
|
+
raw=str(error) or type(error).__name__
|
|
39
|
+
messages={
|
|
40
|
+
'App-server refused thread/goal/get':'无法查询 goal 状态;可加 --force 强制关闭旧服务后切换。',
|
|
41
|
+
'Force mode requires Linux pidfd support.':'当前 Python 不支持安全强制终止,请使用带 pidfd 支持的系统 Python。',
|
|
42
|
+
'Forced service shutdown failed; configuration unchanged.':'强制关闭失败,配置未切换。',
|
|
43
|
+
'Cancelled; no tasks interrupted and configuration unchanged.':'已取消,任务和配置均未更改。',
|
|
44
|
+
'Confirmation requires an interactive terminal; no tasks interrupted.':'请在交互终端中确认;尚未中断任务。',
|
|
45
|
+
'App-server did not exit gracefully; configuration unchanged. Paused goals remain paused. No force-kill was used.':'服务退出超时,未切换,也未强杀。已暂停的 goal 保持暂停。',
|
|
46
|
+
'Interruption timed out; configuration unchanged. No force-kill was used.':'任务停止超时,未切换,也未强杀。',
|
|
47
|
+
'Another login or switch operation is running; finish it first.':'已有登录或切换操作正在运行,请等待完成。',
|
|
48
|
+
'ChatGPT login missing. Run codex-mode login first; existing mode remains unchanged.':'未登录 ChatGPT,请先运行 codex-mode login。',
|
|
49
|
+
'A new active goal appeared during confirmation; retry.':'确认期间出现新 goal,已取消切换,请重试。',
|
|
50
|
+
'A new turn started during confirmation; retry. No new turn interrupted.':'确认期间出现新任务,已取消切换,未中断新任务。',
|
|
51
|
+
'A goal was activated during interruption; switching aborted.':'goal 被重新启用,已取消切换。',
|
|
52
|
+
'Tasks changed before service shutdown; switching aborted.':'任务状态发生变化,已取消切换。',
|
|
53
|
+
'App-server connections changed; retry.':'Desktop 连接发生变化,请断开后重试。',
|
|
54
|
+
'Configuration changed concurrently; retry without overwriting it.':'配置被其他进程修改,未覆盖,请重试。',
|
|
55
|
+
}
|
|
56
|
+
text=messages.get(raw,raw)
|
|
57
|
+
if VERBOSE and text!=raw:text+='\n '+raw
|
|
58
|
+
return text
|
|
59
|
+
API_URL = ''
|
|
60
|
+
CHAT_URL = 'https://chatgpt.com/backend-api/codex'
|
|
61
|
+
BIN = 'codex'
|
|
62
|
+
ALIASES = ['direct']
|
|
63
|
+
HEALTH_URL = None
|
|
64
|
+
|
|
65
|
+
def atomic(path, data):
|
|
66
|
+
path = Path(path)
|
|
67
|
+
fd, name = tempfile.mkstemp(prefix=path.name+'.new-', dir=path.parent)
|
|
68
|
+
try:
|
|
69
|
+
os.fchmod(fd, 0o600)
|
|
70
|
+
with os.fdopen(fd, 'wb') as f:
|
|
71
|
+
f.write(data if isinstance(data, bytes) else data.encode())
|
|
72
|
+
f.flush(); os.fsync(f.fileno())
|
|
73
|
+
os.replace(name, path)
|
|
74
|
+
finally:
|
|
75
|
+
if os.path.exists(name): os.unlink(name)
|
|
76
|
+
|
|
77
|
+
def read_auth(path):
|
|
78
|
+
try:
|
|
79
|
+
value=json.loads(path.read_text())
|
|
80
|
+
if not isinstance(value,dict):raise ValueError('Invalid JSON object in '+path.name)
|
|
81
|
+
return value
|
|
82
|
+
except FileNotFoundError: return {}
|
|
83
|
+
|
|
84
|
+
def chat_auth(a):
|
|
85
|
+
tokens=a.get('tokens') or {}
|
|
86
|
+
return isinstance(tokens,dict) and bool(tokens.get('access_token')) and a.get('auth_mode') != 'apikey'
|
|
87
|
+
|
|
88
|
+
def key_for(root):
|
|
89
|
+
settings=read_auth(root/'codex-mode.json')
|
|
90
|
+
name=settings.get('key_env')
|
|
91
|
+
if name:
|
|
92
|
+
key=os.environ.get(name,'').strip()
|
|
93
|
+
if not key:raise RuntimeError('Missing API key environment variable: '+name)
|
|
94
|
+
return key
|
|
95
|
+
a = read_auth(root/'auth-profiles/sub2api.auth.json')
|
|
96
|
+
key = a.get('OPENAI_API_KEY')
|
|
97
|
+
if not isinstance(key, str) or not key.strip(): raise RuntimeError('Saved sub2api key is missing; setup required.')
|
|
98
|
+
return key.strip()
|
|
99
|
+
|
|
100
|
+
def codex_env(root):
|
|
101
|
+
env = os.environ.copy(); env['CODEX_HOME'] = str(root)
|
|
102
|
+
for k in ('OPENAI_API_KEY', 'OPENAI_BASE_URL', 'CODEX_API_KEY', 'CODEX_ACCESS_TOKEN'):
|
|
103
|
+
env.pop(k, None)
|
|
104
|
+
return env
|
|
105
|
+
|
|
106
|
+
def render(text, root, mode):
|
|
107
|
+
document=tomlkit.parse(text)
|
|
108
|
+
settings=read_auth(root/'codex-mode.json')
|
|
109
|
+
model=settings.get('chatgpt_model' if mode=='chatgpt' else 'api_model')
|
|
110
|
+
if model:document['model']=model
|
|
111
|
+
document['model_provider']='direct'
|
|
112
|
+
document['cli_auth_credentials_store']='file'
|
|
113
|
+
document.pop('forced_login_method',None)
|
|
114
|
+
providers=document.setdefault('model_providers',tomlkit.table())
|
|
115
|
+
for provider in ALIASES:
|
|
116
|
+
p=tomlkit.table()
|
|
117
|
+
p.update({'name':mode+' (shared history)','base_url':CHAT_URL if mode=='chatgpt' else API_URL,
|
|
118
|
+
'wire_api':'responses','supports_websockets':False})
|
|
119
|
+
if mode=='chatgpt':p['requires_openai_auth']=True
|
|
120
|
+
else:
|
|
121
|
+
# Use the installed Python interpreter, not an executable source file.
|
|
122
|
+
p['auth']={'command':sys.executable,'args':['-m','codex_mode','--state-dir',str(root),'_key'],
|
|
123
|
+
'refresh_interval_ms':0,'timeout_ms':5000}
|
|
124
|
+
providers[provider]=p
|
|
125
|
+
out=tomlkit.dumps(document)
|
|
126
|
+
parsed=tomllib.loads(out)
|
|
127
|
+
assert parsed['model_provider']=='direct'
|
|
128
|
+
assert parsed['model_providers']['direct']['base_url']==(CHAT_URL if mode=='chatgpt' else API_URL)
|
|
129
|
+
return out
|
|
130
|
+
|
|
131
|
+
def processes(root):
|
|
132
|
+
if sys.platform!='linux':raise RuntimeError('The first release supports Linux only.')
|
|
133
|
+
found=[]
|
|
134
|
+
for p in Path('/proc').iterdir():
|
|
135
|
+
if not p.name.isdigit():continue
|
|
136
|
+
try:
|
|
137
|
+
if p.stat().st_uid!=os.getuid():continue
|
|
138
|
+
argv=(p/'cmdline').read_bytes().decode().split('\0')
|
|
139
|
+
if 'app-server' not in argv:continue
|
|
140
|
+
env=dict(v.split('=',1) for v in (p/'environ').read_bytes().decode().split('\0') if '=' in v)
|
|
141
|
+
used=Path(env.get('CODEX_HOME',str(Path.home()/'.codex'))).resolve()
|
|
142
|
+
if used!=root:continue
|
|
143
|
+
exe=(p/'exe').resolve()
|
|
144
|
+
if exe.name!='codex':continue
|
|
145
|
+
found.append({'pid':int(p.name),'proxy':'proxy' in argv,'listen':'--listen' in argv,'exe':str(exe),'start':(p/'stat').read_text().split()[21]})
|
|
146
|
+
except (OSError,ValueError):continue
|
|
147
|
+
return found
|
|
148
|
+
|
|
149
|
+
class RpcError(RuntimeError):
|
|
150
|
+
def __init__(self,method,error):
|
|
151
|
+
super().__init__('App-server refused '+method)
|
|
152
|
+
self.method=method;self.code=error.get('code');self.message=error.get('message','')
|
|
153
|
+
|
|
154
|
+
class Probe:
|
|
155
|
+
"""JSON RPC through the Desktop Unix WebSocket control socket."""
|
|
156
|
+
def __init__(self,root,proxy=True):
|
|
157
|
+
self.n=0;self.buf=b'';self.deadline=None
|
|
158
|
+
self.sock=socket.socket(socket.AF_UNIX,socket.SOCK_STREAM)
|
|
159
|
+
self.sock.settimeout(12)
|
|
160
|
+
try:
|
|
161
|
+
self.sock.connect(str(root/'app-server-control/app-server-control.sock'))
|
|
162
|
+
key=base64.b64encode(os.urandom(16)).decode()
|
|
163
|
+
self.sock.sendall(('GET / HTTP/1.1\r\nHost: localhost\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: '+key+'\r\nSec-WebSocket-Version: 13\r\n\r\n').encode())
|
|
164
|
+
header=b''
|
|
165
|
+
while b'\r\n\r\n' not in header:
|
|
166
|
+
part=self.sock.recv(4096)
|
|
167
|
+
if not part:raise RuntimeError('Control socket closed during handshake.')
|
|
168
|
+
header+=part
|
|
169
|
+
if len(header)>65536:raise RuntimeError('Oversized control handshake.')
|
|
170
|
+
header,self.buf=header.split(b'\r\n\r\n',1)
|
|
171
|
+
rows=header.decode().split('\r\n')
|
|
172
|
+
headers=dict((k.lower().strip(),v.strip()) for k,v in (r.split(':',1) for r in rows[1:]))
|
|
173
|
+
accept=base64.b64encode(hashlib.sha1((key+'258EAFA5-E914-47DA-95CA-C5AB0DC85B11').encode()).digest()).decode()
|
|
174
|
+
if rows[0].split()[1]!='101' or headers.get('sec-websocket-accept')!=accept:
|
|
175
|
+
raise RuntimeError('Invalid control socket handshake.')
|
|
176
|
+
self.call('initialize',{'clientInfo':{'name':'codex_mode','title':'Codex mode manager','version':VERSION},'capabilities':{'experimentalApi':True}})
|
|
177
|
+
self.send(1,b'{"method":"initialized"}')
|
|
178
|
+
except BaseException:
|
|
179
|
+
self.sock.close();raise
|
|
180
|
+
def send(self,opcode,data):
|
|
181
|
+
size=len(data);mask=os.urandom(4)
|
|
182
|
+
length=bytes([0x80|size]) if size<126 else bytes([254])+struct.pack('!H',size) if size<65536 else bytes([255])+struct.pack('!Q',size)
|
|
183
|
+
self.sock.sendall(bytes([0x80|opcode])+length+mask+bytes(b^mask[i%4] for i,b in enumerate(data)))
|
|
184
|
+
def exact(self,size,until):
|
|
185
|
+
while len(self.buf)<size:
|
|
186
|
+
remaining=until-time.monotonic()
|
|
187
|
+
if remaining<=0:raise RuntimeError('App-server status timeout.')
|
|
188
|
+
self.sock.settimeout(remaining)
|
|
189
|
+
part=self.sock.recv(min(65536,size-len(self.buf)))
|
|
190
|
+
if not part:raise RuntimeError('Control socket disconnected.')
|
|
191
|
+
self.buf+=part
|
|
192
|
+
result,self.buf=self.buf[:size],self.buf[size:]
|
|
193
|
+
return result
|
|
194
|
+
def receive(self,until):
|
|
195
|
+
parts=[];total=0
|
|
196
|
+
while True:
|
|
197
|
+
a,b=self.exact(2,until);opcode=a&15;size=b&127
|
|
198
|
+
if size==126:size=struct.unpack('!H',self.exact(2,until))[0]
|
|
199
|
+
elif size==127:size=struct.unpack('!Q',self.exact(8,until))[0]
|
|
200
|
+
if size>16*1024*1024 or b&128 or a&0x70:raise RuntimeError('Invalid control socket frame.')
|
|
201
|
+
data=self.exact(size,until)
|
|
202
|
+
if opcode==8:raise RuntimeError('App-server closed status connection.')
|
|
203
|
+
if opcode==9:self.send(10,data);continue
|
|
204
|
+
if opcode==10:continue
|
|
205
|
+
if opcode not in (0,1):raise RuntimeError('Unexpected control socket frame.')
|
|
206
|
+
parts.append(data);total+=size
|
|
207
|
+
if total>16*1024*1024:raise RuntimeError('Oversized control message.')
|
|
208
|
+
if a&128:return json.loads(b''.join(parts))
|
|
209
|
+
def call(self,method,params):
|
|
210
|
+
self.n+=1;ident=self.n
|
|
211
|
+
self.send(1,json.dumps({'id':ident,'method':method,'params':params}).encode())
|
|
212
|
+
until=min(time.monotonic()+12,self.deadline or float('inf'))
|
|
213
|
+
while time.monotonic()<until:
|
|
214
|
+
msg=self.receive(until)
|
|
215
|
+
if msg.get('id')!=ident:continue
|
|
216
|
+
if 'error' in msg:raise RpcError(method,msg['error'])
|
|
217
|
+
return msg['result']
|
|
218
|
+
raise RuntimeError('App-server status timeout.')
|
|
219
|
+
def close(self):
|
|
220
|
+
with contextlib.suppress(OSError):self.send(8,b'')
|
|
221
|
+
self.sock.close()
|
|
222
|
+
|
|
223
|
+
def snapshot(rpc):
|
|
224
|
+
"""Bounded, metadata-only reads: never load large conversation bodies."""
|
|
225
|
+
result=[];cursor=None;seen=set()
|
|
226
|
+
while True:
|
|
227
|
+
page=rpc.call('thread/loaded/list',{'cursor':cursor})
|
|
228
|
+
for tid in page['data']:
|
|
229
|
+
thread=rpc.call('thread/read',{'threadId':tid,'includeTurns':False})['thread']
|
|
230
|
+
goal=rpc.call('thread/goal/get',{'threadId':tid}).get('goal')
|
|
231
|
+
state=thread.get('status',{}).get('type')
|
|
232
|
+
if state not in ('idle','notLoaded','active','systemError'):
|
|
233
|
+
raise RuntimeError('Unknown task status; configuration unchanged.')
|
|
234
|
+
try:
|
|
235
|
+
turns=rpc.call('thread/turns/list',{'threadId':tid,'limit':1,'sortDirection':'desc','itemsView':'notLoaded'})['data']
|
|
236
|
+
except RpcError as error:
|
|
237
|
+
expected='thread '+tid+' is not materialized yet; thread/turns/list is unavailable before first user message'
|
|
238
|
+
if state in ('idle','notLoaded') and error.code==-32600 and error.message==expected:
|
|
239
|
+
turns=[]
|
|
240
|
+
else:raise
|
|
241
|
+
turn=turns[0] if turns else {}
|
|
242
|
+
terminals=rpc.call('thread/backgroundTerminals/list',{'threadId':tid})
|
|
243
|
+
if terminals['data'] or terminals.get('nextCursor'):
|
|
244
|
+
raise RuntimeError('Task '+tid+' has background terminals. Inspect/stop them manually before switching; they will not be killed automatically.')
|
|
245
|
+
if state=='active' and turn.get('status')!='inProgress':
|
|
246
|
+
raise RuntimeError('Active task '+tid+' has no identifiable running turn; refusing to stop its service.')
|
|
247
|
+
if state=='systemError' and turn.get('status') not in ('failed','interrupted','completed'):
|
|
248
|
+
raise RuntimeError('Cannot verify errored task '+tid+' is stopped.')
|
|
249
|
+
result.append({'id':tid,'name':thread.get('name') or tid,'state':state,
|
|
250
|
+
'turn':turn.get('id'),'turn_status':turn.get('status'),
|
|
251
|
+
'goal_status':goal.get('status') if goal else None})
|
|
252
|
+
cursor=page.get('nextCursor')
|
|
253
|
+
if not cursor:return result
|
|
254
|
+
if cursor in seen:raise RuntimeError('Repeated task-list cursor; refusing to switch.')
|
|
255
|
+
seen.add(cursor)
|
|
256
|
+
|
|
257
|
+
def active_tasks(tasks):
|
|
258
|
+
return [t for t in tasks if t['state']=='active' or t['turn_status']=='inProgress']
|
|
259
|
+
|
|
260
|
+
def confirm_switch(tasks,has_proxy):
|
|
261
|
+
say('切换前需要处理:','warn')
|
|
262
|
+
for t in tasks:
|
|
263
|
+
if t in active_tasks(tasks) or t['state']=='systemError' or t.get('goal_status')=='active':
|
|
264
|
+
label={'active':'运行中','systemError':'异常','idle':'空闲','notLoaded':'未加载'}.get(t['state'],t['state'])
|
|
265
|
+
if t.get('goal_status')=='active':label+=' · goal 将暂停'
|
|
266
|
+
name=str(t['name']);name=name if VERBOSE or len(name)<=60 else name[:59]+'…'
|
|
267
|
+
say(' '+json.dumps(name,ensure_ascii=False)+' '+label,'warn')
|
|
268
|
+
say(' ID: '+t['id'],'info',detail=True)
|
|
269
|
+
if has_proxy:say(' Desktop 连接将断开。','warn')
|
|
270
|
+
say('保留聊天、目标和代码改动;切换后手动继续。期间请勿重连。')
|
|
271
|
+
if not sys.stdin.isatty():raise RuntimeError('Confirmation requires an interactive terminal; no tasks interrupted.')
|
|
272
|
+
if input(color('继续切换?[y/N] ','warn')).strip().lower() not in ('y','yes'):
|
|
273
|
+
raise RuntimeError('Cancelled; no tasks interrupted and configuration unchanged.')
|
|
274
|
+
|
|
275
|
+
def quiesce(rpc,tasks,has_proxy,disconnect_idle=False):
|
|
276
|
+
running=active_tasks(tasks)
|
|
277
|
+
errors={t['id'] for t in tasks if t['state']=='systemError'}
|
|
278
|
+
goals={t['id'] for t in tasks if t.get('goal_status')=='active'}
|
|
279
|
+
if running or errors or goals or (has_proxy and not disconnect_idle):confirm_switch(tasks,has_proxy)
|
|
280
|
+
approved={(t['id'],t['turn']) for t in running}
|
|
281
|
+
# Re-read after the human prompt; never interrupt a newly started turn.
|
|
282
|
+
fresh=snapshot(rpc)
|
|
283
|
+
if any(t.get('goal_status')=='active' and t['id'] not in goals for t in fresh):
|
|
284
|
+
raise RuntimeError('A new active goal appeared during confirmation; retry.')
|
|
285
|
+
if any((t['id'],t['turn']) not in approved for t in active_tasks(fresh)):
|
|
286
|
+
raise RuntimeError('A new turn started during confirmation; retry. No new turn interrupted.')
|
|
287
|
+
rpc.deadline=time.monotonic()+45
|
|
288
|
+
try:
|
|
289
|
+
if goals or active_tasks(fresh):say('处理中:'+('暂停 goal / ' if goals else '')+'停止任务…')
|
|
290
|
+
for t in fresh:
|
|
291
|
+
if t['id'] in goals and t.get('goal_status')=='active':
|
|
292
|
+
# Omit objective and tokenBudget to preserve accumulated usage and budget.
|
|
293
|
+
result=rpc.call('thread/goal/set',{'threadId':t['id'],'status':'paused'})
|
|
294
|
+
if (result.get('goal') or {}).get('status')!='paused':
|
|
295
|
+
raise RuntimeError('Goal pause not confirmed; refusing to stop the service.')
|
|
296
|
+
PAUSED_GOALS.append(t['id'])
|
|
297
|
+
say(' goal 已暂停:'+t['id'],detail=True)
|
|
298
|
+
if goals:
|
|
299
|
+
fresh=snapshot(rpc)
|
|
300
|
+
if any(t.get('goal_status')=='active' for t in fresh):
|
|
301
|
+
raise RuntimeError('A goal is still active; switching aborted.')
|
|
302
|
+
if any((t['id'],t['turn']) not in approved for t in active_tasks(fresh)):
|
|
303
|
+
raise RuntimeError('A new turn started while pausing goals; retry. Paused goals remain paused.')
|
|
304
|
+
for t in active_tasks(fresh):
|
|
305
|
+
rpc.call('turn/interrupt',{'threadId':t['id'],'turnId':t['turn']})
|
|
306
|
+
say(' 已请求中断:'+t['id'],detail=True)
|
|
307
|
+
while True:
|
|
308
|
+
current=snapshot(rpc)
|
|
309
|
+
if any(t.get('goal_status')=='active' for t in current):
|
|
310
|
+
raise RuntimeError('A goal was activated during interruption; switching aborted.')
|
|
311
|
+
if any((t['id'],t['turn']) not in approved for t in active_tasks(current)):
|
|
312
|
+
raise RuntimeError('A new turn started while waiting; switching aborted.')
|
|
313
|
+
if not active_tasks(current):
|
|
314
|
+
by_id={t['id']:t for t in current}
|
|
315
|
+
# A disappeared task cannot provide positive completion confirmation.
|
|
316
|
+
if any(tid not in by_id or by_id[tid]['turn']!=turn or by_id[tid]['turn_status'] not in ('interrupted','completed','failed') for tid,turn in approved):
|
|
317
|
+
raise RuntimeError('Cannot confirm interrupted turns were persisted; switching aborted.')
|
|
318
|
+
if any(t['state']=='systemError' and t['id'] not in errors and (t['id'],t['turn']) not in approved for t in current):
|
|
319
|
+
raise RuntimeError('A new task error appeared; retry after inspection.')
|
|
320
|
+
return current
|
|
321
|
+
if time.monotonic()>=rpc.deadline:raise RuntimeError('Interruption timed out; configuration unchanged. No force-kill was used.')
|
|
322
|
+
time.sleep(.25)
|
|
323
|
+
finally:rpc.deadline=None
|
|
324
|
+
|
|
325
|
+
def force_stop(root):
|
|
326
|
+
targets=processes(root)
|
|
327
|
+
if not targets:return
|
|
328
|
+
say('将关闭 '+str(len(targets))+' 个 Codex 进程;未保存输出可能丢失。','warn')
|
|
329
|
+
for p in targets:
|
|
330
|
+
say(' PID '+str(p['pid'])+' · '+('Desktop 代理' if p['proxy'] else 'Codex 服务'),'warn',detail=True)
|
|
331
|
+
say('聊天和代码保留;goal 不会暂停,外部进程可能继续运行。','warn')
|
|
332
|
+
if not sys.stdin.isatty():raise RuntimeError('Confirmation requires an interactive terminal; no tasks interrupted.')
|
|
333
|
+
if input(color('强制切换?[y/N] ','warn')).strip().lower() not in ('y','yes'):
|
|
334
|
+
raise RuntimeError('Cancelled; no tasks interrupted and configuration unchanged.')
|
|
335
|
+
original={(p['pid'],p['start'],p['exe']) for p in targets}
|
|
336
|
+
def remaining():
|
|
337
|
+
current=processes(root)
|
|
338
|
+
if any((p['pid'],p['start'],p['exe']) not in original for p in current):
|
|
339
|
+
raise RuntimeError('App-server connections changed; retry.')
|
|
340
|
+
return current
|
|
341
|
+
def send(p,sig):
|
|
342
|
+
# A pidfd pins the exact process across PID reuse; recheck scope before signaling.
|
|
343
|
+
if not hasattr(os,'pidfd_open') or not hasattr(signal,'pidfd_send_signal'):
|
|
344
|
+
raise RuntimeError('Force mode requires Linux pidfd support.')
|
|
345
|
+
try:fd=os.pidfd_open(p['pid'])
|
|
346
|
+
except ProcessLookupError:return
|
|
347
|
+
try:
|
|
348
|
+
if not any((q['pid'],q['start'],q['exe'])==(p['pid'],p['start'],p['exe']) for q in remaining()):return
|
|
349
|
+
signal.pidfd_send_signal(fd,sig)
|
|
350
|
+
except ProcessLookupError:pass
|
|
351
|
+
finally:os.close(fd)
|
|
352
|
+
say('停止旧服务…')
|
|
353
|
+
for p in sorted(remaining(),key=lambda p:not p['proxy']):send(p,signal.SIGTERM)
|
|
354
|
+
deadline=time.monotonic()+5
|
|
355
|
+
while remaining() and time.monotonic()<deadline:time.sleep(.1)
|
|
356
|
+
pending=remaining()
|
|
357
|
+
if pending:
|
|
358
|
+
say('服务未退出,执行强制终止…','warn')
|
|
359
|
+
for p in pending:send(p,signal.SIGKILL)
|
|
360
|
+
deadline=time.monotonic()+5
|
|
361
|
+
while remaining() and time.monotonic()<deadline:time.sleep(.1)
|
|
362
|
+
if remaining():raise RuntimeError('Forced service shutdown failed; configuration unchanged.')
|
|
363
|
+
|
|
364
|
+
def ensure_idle(root,disconnect_idle=False,allow_interrupt=False):
|
|
365
|
+
running=processes(root)
|
|
366
|
+
listeners=[p for p in running if p['listen']]
|
|
367
|
+
if running and (len(listeners)!=1 or any(not p['proxy'] and not p['listen'] for p in running)):
|
|
368
|
+
raise RuntimeError('Cannot verify all app-server processes; configuration unchanged.')
|
|
369
|
+
if listeners:
|
|
370
|
+
rpc=None
|
|
371
|
+
try:
|
|
372
|
+
rpc=Probe(root,proxy=True)
|
|
373
|
+
if allow_interrupt:
|
|
374
|
+
tasks=quiesce(rpc,snapshot(rpc),any(p['proxy'] for p in running),disconnect_idle)
|
|
375
|
+
# Repeat the metadata check immediately before any service termination.
|
|
376
|
+
final=snapshot(rpc)
|
|
377
|
+
if active_tasks(final) or any(t.get('goal_status')=='active' for t in final) or {(t['id'],t['turn'],t['turn_status']) for t in final}!={(t['id'],t['turn'],t['turn_status']) for t in tasks}:
|
|
378
|
+
raise RuntimeError('Tasks changed before service shutdown; switching aborted.')
|
|
379
|
+
else:
|
|
380
|
+
self_check_idle(rpc)
|
|
381
|
+
self_check_idle(rpc)
|
|
382
|
+
current=processes(root)
|
|
383
|
+
if {(p['pid'],p['start']) for p in current}!={(p['pid'],p['start']) for p in running}:
|
|
384
|
+
raise RuntimeError('App-server connections changed; retry.')
|
|
385
|
+
if any(p['proxy'] for p in current) and not (disconnect_idle or allow_interrupt):
|
|
386
|
+
raise RuntimeError('All tasks are idle, but a Desktop proxy remains. Retry with --disconnect-idle to disconnect it safely before switching.')
|
|
387
|
+
for p in sorted(current,key=lambda p:not p['proxy']):
|
|
388
|
+
proc=Path('/proc')/str(p['pid'])
|
|
389
|
+
try:
|
|
390
|
+
if (proc/'stat').read_text().split()[21]!=p['start']:raise RuntimeError('App-server process changed; retry.')
|
|
391
|
+
os.kill(p['pid'],signal.SIGTERM)
|
|
392
|
+
except FileNotFoundError:pass
|
|
393
|
+
except (queue.Empty,BrokenPipeError):raise RuntimeError('Cannot verify app-server is idle; no configuration changed.')
|
|
394
|
+
finally:
|
|
395
|
+
if rpc:rpc.close()
|
|
396
|
+
say('等待旧服务退出…',detail=True)
|
|
397
|
+
deadline=time.monotonic()+30
|
|
398
|
+
while processes(root) and time.monotonic()<deadline:time.sleep(.1)
|
|
399
|
+
if processes(root):raise RuntimeError('旧服务未退出;配置未改动。可用 --force 重试。')
|
|
400
|
+
|
|
401
|
+
def self_check_idle(rpc):
|
|
402
|
+
ids=[];cursor=None
|
|
403
|
+
while True:
|
|
404
|
+
page=rpc.call('thread/loaded/list',{'cursor':cursor})
|
|
405
|
+
ids.extend(page['data']);cursor=page.get('nextCursor')
|
|
406
|
+
if not cursor:break
|
|
407
|
+
blocked=[]
|
|
408
|
+
for tid in ids:
|
|
409
|
+
thread=rpc.call('thread/read',{'threadId':tid,'includeTurns':False})['thread']
|
|
410
|
+
status=thread.get('status',{}).get('type')
|
|
411
|
+
if status not in ('idle','notLoaded'):
|
|
412
|
+
blocked.append(str(thread.get('name') or tid)+' ['+tid+'] ('+str(status)+')')
|
|
413
|
+
if blocked:raise RuntimeError('仍有非空闲会话:'+ '; '.join(blocked)+'。请先手动停止会话及 goal,或使用 --force。')
|
|
414
|
+
|
|
415
|
+
def status(root):
|
|
416
|
+
c=tomllib.loads((root/'config.toml').read_text());provider=c.get('model_provider','openai');p=c.get('model_providers',{}).get(provider,{})
|
|
417
|
+
if not VERBOSE:
|
|
418
|
+
endpoint=p.get('base_url')
|
|
419
|
+
mode='ChatGPT' if endpoint==CHAT_URL else 'API' if API_URL and endpoint==API_URL else provider+'(未托管)'
|
|
420
|
+
say('codex-mode '+VERSION+' | 当前配置:'+mode)
|
|
421
|
+
chat=chat_auth(read_auth(root/'auth.json')) or chat_auth(read_auth(root/'auth-profiles/chatgpt.auth.json'))
|
|
422
|
+
try:key_for(root);api=True
|
|
423
|
+
except RuntimeError:api=False
|
|
424
|
+
say('凭据:ChatGPT '+('已就绪' if chat else '未登录')+' / API '+('已就绪' if api else '缺失'))
|
|
425
|
+
running=processes(root)
|
|
426
|
+
say('服务:'+('运行中(现有进程沿用启动配置)' if running else '未运行'))
|
|
427
|
+
return
|
|
428
|
+
print('codex-mode',VERSION)
|
|
429
|
+
print('State directory:',root)
|
|
430
|
+
print('Configured provider:',provider)
|
|
431
|
+
print('Configured endpoint:',p.get('base_url','built-in OpenAI'))
|
|
432
|
+
print('Mode:', 'chatgpt' if p.get('base_url')==CHAT_URL else 'api' if API_URL and p.get('base_url')==API_URL else 'unmanaged')
|
|
433
|
+
print('ChatGPT login:', 'available' if chat_auth(read_auth(root/'auth.json')) else 'saved; activate by switching' if chat_auth(read_auth(root/'auth-profiles/chatgpt.auth.json')) else 'missing: run codex-mode login')
|
|
434
|
+
try:key_for(root);print('API credential: available (not displayed)')
|
|
435
|
+
except RuntimeError:print('API credential: missing')
|
|
436
|
+
for p in processes(root):print('Running:',p['pid'],'Desktop proxy' if p['proxy'] else 'app-server',p['exe'])
|
|
437
|
+
print('Active processes retain their startup settings until reconnected.')
|
|
438
|
+
|
|
439
|
+
def switch(root,mode,disconnect_idle=False,force=False):
|
|
440
|
+
PAUSED_GOALS.clear()
|
|
441
|
+
say('切换到 '+('ChatGPT' if mode=='chatgpt' else 'API')+' · 检查中…')
|
|
442
|
+
profile=root/'auth-profiles';profile.mkdir(mode=0o700,exist_ok=True)
|
|
443
|
+
if mode=='sub2api':
|
|
444
|
+
if not API_URL:raise RuntimeError('API endpoint is not configured. Run codex-mode init --api-url URL.')
|
|
445
|
+
key_for(root)
|
|
446
|
+
if HEALTH_URL:
|
|
447
|
+
import urllib.request
|
|
448
|
+
try:
|
|
449
|
+
with urllib.request.urlopen(HEALTH_URL,timeout=5) as response:
|
|
450
|
+
if response.status!=200:raise RuntimeError('API health check failed.')
|
|
451
|
+
except OSError:raise RuntimeError('API health check failed; inspect the configured endpoint.')
|
|
452
|
+
auth=read_auth(root/'auth.json');saved=read_auth(profile/'chatgpt.auth.json')
|
|
453
|
+
if mode=='chatgpt' and not chat_auth(auth) and not chat_auth(saved):raise RuntimeError('ChatGPT login missing. Run codex-mode login first; existing mode remains unchanged.')
|
|
454
|
+
original=(root/'config.toml').read_text();new=render(original,root,mode)
|
|
455
|
+
if force:force_stop(root)
|
|
456
|
+
else:ensure_idle(root,disconnect_idle=disconnect_idle,allow_interrupt=False)
|
|
457
|
+
# OAuth tokens may have refreshed while waiting; preserve the latest credentials.
|
|
458
|
+
auth=read_auth(root/'auth.json');saved=read_auth(profile/'chatgpt.auth.json')
|
|
459
|
+
if (root/'config.toml').read_text()!=original:
|
|
460
|
+
raise RuntimeError('Configuration changed concurrently; retry without overwriting it.')
|
|
461
|
+
backup=root/'mode-backups'/datetime.datetime.now().strftime('%Y%m%d-%H%M%S-%f');backup.mkdir(parents=True,mode=0o700)
|
|
462
|
+
for n in ('config.toml','auth.json'):
|
|
463
|
+
if (root/n).exists():shutil.copy2(root/n,backup/n);os.chmod(backup/n,0o600)
|
|
464
|
+
try:
|
|
465
|
+
if mode=='chatgpt' and not chat_auth(auth):atomic(root/'auth.json',json.dumps(saved))
|
|
466
|
+
atomic(root/'config.toml',new)
|
|
467
|
+
if chat_auth(read_auth(root/'auth.json')):atomic(profile/'chatgpt.auth.json',(root/'auth.json').read_bytes())
|
|
468
|
+
except BaseException:
|
|
469
|
+
for n in ('config.toml','auth.json'):
|
|
470
|
+
if (backup/n).exists():atomic(root/n,(backup/n).read_bytes())
|
|
471
|
+
raise
|
|
472
|
+
say('✓ 已切换到 '+('ChatGPT' if mode=='chatgpt' else 'API')+',聊天记录保留。','ok')
|
|
473
|
+
say('重连 Desktop 后,在原聊天继续。')
|
|
474
|
+
say('备份:'+str(backup),detail=True)
|
|
475
|
+
|
|
476
|
+
def login(root):
|
|
477
|
+
profile=root/'auth-profiles';profile.mkdir(mode=0o700,exist_ok=True)
|
|
478
|
+
staging=Path(tempfile.mkdtemp(prefix='chatgpt-login-',dir=profile))
|
|
479
|
+
atomic(staging/'config.toml','model_provider = "openai"\ncli_auth_credentials_store = "file"\n')
|
|
480
|
+
try:
|
|
481
|
+
result=subprocess.run([BIN,'login','--device-auth'],env=codex_env(staging))
|
|
482
|
+
if result.returncode:raise RuntimeError('Login did not complete; current auth unchanged.')
|
|
483
|
+
data=read_auth(staging/'auth.json')
|
|
484
|
+
if not chat_auth(data):raise RuntimeError('Expected a ChatGPT login.')
|
|
485
|
+
atomic(profile/'chatgpt.auth.json',json.dumps(data))
|
|
486
|
+
say('✓ ChatGPT 登录已保存。运行 codex-mode chatgpt 切换。','ok')
|
|
487
|
+
finally:shutil.rmtree(staging)
|