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/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)