@topolo/cli 0.12.32 → 0.13.0
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.
- package/README.md +62 -0
- package/agent-packs/README.md +341 -0
- package/agent-packs/pyproject.toml +16 -0
- package/agent-packs/requirements-test.txt +1 -0
- package/agent-packs/src/topolo_agent_pack/__init__.py +1 -0
- package/agent-packs/src/topolo_agent_pack/blueprint.py +57 -0
- package/agent-packs/src/topolo_agent_pack/cli.py +416 -0
- package/agent-packs/src/topolo_agent_pack/fresh.py +158 -0
- package/agent-packs/src/topolo_agent_pack/live.py +362 -0
- package/agent-packs/src/topolo_agent_pack/native.py +645 -0
- package/agent-packs/src/topolo_agent_pack/storage.py +271 -0
- package/agent-packs/src/topolo_agent_pack/topolo.py +321 -0
- package/agent-packs/templates/career/AGENTS.md +17 -0
- package/agent-packs/templates/career/README.md +14 -0
- package/agent-packs/templates/career/agents/intakes/career-intake/AGENTS.md +21 -0
- package/agent-packs/templates/career/agents/specialists/application-preparation/AGENTS.md +21 -0
- package/agent-packs/templates/career/agents/specialists/application-tracker/AGENTS.md +21 -0
- package/agent-packs/templates/career/agents/specialists/interview-coach/AGENTS.md +21 -0
- package/agent-packs/templates/career/agents/specialists/opportunity-research/AGENTS.md +21 -0
- package/agent-packs/templates/career/agents/specialists/profile-cv/AGENTS.md +21 -0
- package/agent-packs/templates/career/pack.json +124 -0
- package/agent-packs/templates/career/shared/PACK.md +22 -0
- package/agent-packs/templates/career/shared/TOPOLO.md +13 -0
- package/agent-packs/templates/career/shared/WORKFLOW.md +13 -0
- package/agent-packs/templates/career/skills/topolo/SKILL.md +305 -0
- package/agent-packs/templates/clients/AGENTS.md +17 -0
- package/agent-packs/templates/clients/README.md +14 -0
- package/agent-packs/templates/clients/agents/intakes/client-intake/AGENTS.md +21 -0
- package/agent-packs/templates/clients/agents/specialists/acceptance-review/AGENTS.md +21 -0
- package/agent-packs/templates/clients/agents/specialists/delivery-planner/AGENTS.md +21 -0
- package/agent-packs/templates/clients/agents/specialists/discovery/AGENTS.md +21 -0
- package/agent-packs/templates/clients/agents/specialists/proposal-writer/AGENTS.md +21 -0
- package/agent-packs/templates/clients/agents/specialists/solution-design/AGENTS.md +21 -0
- package/agent-packs/templates/clients/pack.json +126 -0
- package/agent-packs/templates/clients/shared/PACK.md +22 -0
- package/agent-packs/templates/clients/shared/TOPOLO.md +13 -0
- package/agent-packs/templates/clients/shared/WORKFLOW.md +13 -0
- package/agent-packs/templates/clients/skills/topolo/SKILL.md +305 -0
- package/agent-packs/templates/creative/AGENTS.md +17 -0
- package/agent-packs/templates/creative/README.md +14 -0
- package/agent-packs/templates/creative/agents/intakes/creative-director/AGENTS.md +21 -0
- package/agent-packs/templates/creative/agents/specialists/brand-copy/AGENTS.md +21 -0
- package/agent-packs/templates/creative/agents/specialists/media-production/AGENTS.md +21 -0
- package/agent-packs/templates/creative/agents/specialists/quality-review/AGENTS.md +21 -0
- package/agent-packs/templates/creative/agents/specialists/storytelling/AGENTS.md +21 -0
- package/agent-packs/templates/creative/agents/specialists/visual-design/AGENTS.md +21 -0
- package/agent-packs/templates/creative/pack.json +128 -0
- package/agent-packs/templates/creative/shared/PACK.md +22 -0
- package/agent-packs/templates/creative/shared/TOPOLO.md +13 -0
- package/agent-packs/templates/creative/shared/WORKFLOW.md +13 -0
- package/agent-packs/templates/creative/skills/topolo/SKILL.md +305 -0
- package/agent-packs/templates/founder/AGENTS.md +17 -0
- package/agent-packs/templates/founder/README.md +14 -0
- package/agent-packs/templates/founder/agents/intakes/founder-intake/AGENTS.md +21 -0
- package/agent-packs/templates/founder/agents/specialists/commercial-modelling/AGENTS.md +21 -0
- package/agent-packs/templates/founder/agents/specialists/customer-research/AGENTS.md +21 -0
- package/agent-packs/templates/founder/agents/specialists/decision-review/AGENTS.md +21 -0
- package/agent-packs/templates/founder/agents/specialists/partnerships/AGENTS.md +21 -0
- package/agent-packs/templates/founder/agents/specialists/strategy/AGENTS.md +21 -0
- package/agent-packs/templates/founder/pack.json +126 -0
- package/agent-packs/templates/founder/shared/PACK.md +22 -0
- package/agent-packs/templates/founder/shared/TOPOLO.md +13 -0
- package/agent-packs/templates/founder/shared/WORKFLOW.md +13 -0
- package/agent-packs/templates/founder/skills/topolo/SKILL.md +305 -0
- package/agent-packs/templates/learning/AGENTS.md +17 -0
- package/agent-packs/templates/learning/README.md +14 -0
- package/agent-packs/templates/learning/agents/intakes/learning-intake/AGENTS.md +21 -0
- package/agent-packs/templates/learning/agents/specialists/critical-reviewer/AGENTS.md +21 -0
- package/agent-packs/templates/learning/agents/specialists/curriculum-designer/AGENTS.md +21 -0
- package/agent-packs/templates/learning/agents/specialists/knowledge-steward/AGENTS.md +21 -0
- package/agent-packs/templates/learning/agents/specialists/practice-coach/AGENTS.md +21 -0
- package/agent-packs/templates/learning/agents/specialists/tutor/AGENTS.md +21 -0
- package/agent-packs/templates/learning/pack.json +124 -0
- package/agent-packs/templates/learning/shared/PACK.md +22 -0
- package/agent-packs/templates/learning/shared/TOPOLO.md +13 -0
- package/agent-packs/templates/learning/shared/WORKFLOW.md +13 -0
- package/agent-packs/templates/learning/skills/topolo/SKILL.md +305 -0
- package/agent-packs/templates/personal/AGENTS.md +17 -0
- package/agent-packs/templates/personal/README.md +14 -0
- package/agent-packs/templates/personal/agents/intakes/personal-intake/AGENTS.md +21 -0
- package/agent-packs/templates/personal/agents/specialists/correspondence/AGENTS.md +21 -0
- package/agent-packs/templates/personal/agents/specialists/document-assistant/AGENTS.md +21 -0
- package/agent-packs/templates/personal/agents/specialists/planning/AGENTS.md +21 -0
- package/agent-packs/templates/personal/agents/specialists/research/AGENTS.md +21 -0
- package/agent-packs/templates/personal/agents/specialists/travel-logistics/AGENTS.md +21 -0
- package/agent-packs/templates/personal/pack.json +125 -0
- package/agent-packs/templates/personal/shared/PACK.md +22 -0
- package/agent-packs/templates/personal/shared/TOPOLO.md +13 -0
- package/agent-packs/templates/personal/shared/WORKFLOW.md +13 -0
- package/agent-packs/templates/personal/skills/topolo/SKILL.md +305 -0
- package/agent-packs/templates/research/AGENTS.md +17 -0
- package/agent-packs/templates/research/README.md +14 -0
- package/agent-packs/templates/research/agents/intakes/research-intake/AGENTS.md +21 -0
- package/agent-packs/templates/research/agents/specialists/adversarial-review/AGENTS.md +21 -0
- package/agent-packs/templates/research/agents/specialists/data-analysis/AGENTS.md +21 -0
- package/agent-packs/templates/research/agents/specialists/source-research/AGENTS.md +21 -0
- package/agent-packs/templates/research/agents/specialists/synthesis/AGENTS.md +21 -0
- package/agent-packs/templates/research/agents/specialists/technical-research/AGENTS.md +21 -0
- package/agent-packs/templates/research/pack.json +124 -0
- package/agent-packs/templates/research/shared/PACK.md +22 -0
- package/agent-packs/templates/research/shared/TOPOLO.md +13 -0
- package/agent-packs/templates/research/shared/WORKFLOW.md +13 -0
- package/agent-packs/templates/research/skills/topolo/SKILL.md +305 -0
- package/agent-packs/templates/ventures/AGENTS.md +17 -0
- package/agent-packs/templates/ventures/README.md +14 -0
- package/agent-packs/templates/ventures/agents/intakes/venture-intake/AGENTS.md +21 -0
- package/agent-packs/templates/ventures/agents/specialists/customer-discovery/AGENTS.md +21 -0
- package/agent-packs/templates/ventures/agents/specialists/economics/AGENTS.md +21 -0
- package/agent-packs/templates/ventures/agents/specialists/experiment-design/AGENTS.md +21 -0
- package/agent-packs/templates/ventures/agents/specialists/market-research/AGENTS.md +21 -0
- package/agent-packs/templates/ventures/agents/specialists/product-concept/AGENTS.md +21 -0
- package/agent-packs/templates/ventures/pack.json +126 -0
- package/agent-packs/templates/ventures/shared/PACK.md +22 -0
- package/agent-packs/templates/ventures/shared/TOPOLO.md +13 -0
- package/agent-packs/templates/ventures/shared/WORKFLOW.md +13 -0
- package/agent-packs/templates/ventures/skills/topolo/SKILL.md +305 -0
- package/agent-packs/topolo-agent-pack +8 -0
- package/dist/index.js +119 -51
- package/package.json +7 -4
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"""Owned desktop backend proxy and same-window reconnect adapter.
|
|
2
|
+
|
|
3
|
+
The proxy replaces only its own idle native child. It never signals the desktop
|
|
4
|
+
or another Codex profile. The renderer bridge is available only in the managed
|
|
5
|
+
profile explicitly launched with local debugging enabled.
|
|
6
|
+
"""
|
|
7
|
+
import asyncio
|
|
8
|
+
import base64
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import pathlib
|
|
13
|
+
import secrets
|
|
14
|
+
import signal
|
|
15
|
+
import re
|
|
16
|
+
import sqlite3
|
|
17
|
+
import socket
|
|
18
|
+
import struct
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
import time
|
|
22
|
+
import urllib.request
|
|
23
|
+
import urllib.parse
|
|
24
|
+
import uuid
|
|
25
|
+
|
|
26
|
+
from .storage import Library,PackError,atomic_json,canonical,read_json
|
|
27
|
+
|
|
28
|
+
def socket_path(lib):
|
|
29
|
+
directory=pathlib.Path('/tmp')/('topolo-agent-pack-'+hashlib.sha256(str(lib.root).encode()).hexdigest()[:16])
|
|
30
|
+
if directory.exists() and (directory.is_symlink() or directory.stat().st_uid!=os.getuid()): raise PackError('Unowned live bridge directory')
|
|
31
|
+
directory.mkdir(mode=0o700,exist_ok=True)
|
|
32
|
+
return directory/'control.sock'
|
|
33
|
+
|
|
34
|
+
def request(lib,operation):
|
|
35
|
+
record=read_json(lib.root/'live-bridge.json',{})
|
|
36
|
+
if not record: raise PackError('This desktop profile has no live bridge. Open it through topolo-agent-pack first.')
|
|
37
|
+
with socket.socket(socket.AF_UNIX,socket.SOCK_STREAM) as connection:
|
|
38
|
+
connection.settimeout(3 if operation=='status' else 20)
|
|
39
|
+
try: connection.connect(record['socket'])
|
|
40
|
+
except OSError as exc: raise PackError('Managed live bridge is unavailable: '+str(exc)) from exc
|
|
41
|
+
connection.sendall(canonical({'token':record['token'],'operation':operation})+b'\n')
|
|
42
|
+
with connection.makefile('rb') as stream:
|
|
43
|
+
line=stream.readline(1024*1024)
|
|
44
|
+
if not line: raise PackError('Live bridge closed without a reply')
|
|
45
|
+
reply=json.loads(line)
|
|
46
|
+
if 'error' in reply: raise PackError(reply['error'])
|
|
47
|
+
return reply
|
|
48
|
+
|
|
49
|
+
def available(lib):
|
|
50
|
+
try: return request(lib,'status')
|
|
51
|
+
except (PackError,OSError,ValueError): return None
|
|
52
|
+
|
|
53
|
+
def quiesce(lib,timeout=10):
|
|
54
|
+
deadline=time.monotonic()+timeout
|
|
55
|
+
while True:
|
|
56
|
+
state=request(lib,'status')
|
|
57
|
+
if state.get('activeTurns'):raise PackError('Outgoing agents have active turns. Wait for their current turns; Codex can stay open.')
|
|
58
|
+
if not state.get('pendingRequests'):
|
|
59
|
+
try:return request(lib,'quiesce')
|
|
60
|
+
except PackError as exc:
|
|
61
|
+
if 'in flight' not in str(exc):raise
|
|
62
|
+
if time.monotonic()>=deadline:raise PackError('Backend requests have not settled; no switch committed. Retry with Codex still open.')
|
|
63
|
+
time.sleep(0.05)
|
|
64
|
+
|
|
65
|
+
class BackendProxy:
|
|
66
|
+
def __init__(self,lib,real,args):
|
|
67
|
+
self.lib=lib;self.real=real;self.args=args;self.child=None
|
|
68
|
+
self.pending=set();self.active_turns=set();self.initialized=False
|
|
69
|
+
self.quiesced=False;self.output_task=None;self.closed=False
|
|
70
|
+
self.token=secrets.token_hex(32);self.path=socket_path(lib)
|
|
71
|
+
|
|
72
|
+
async def start_child(self):
|
|
73
|
+
active=self.lib.active()
|
|
74
|
+
if not active.get('runtime'): raise PackError('No selected managed runtime')
|
|
75
|
+
self.runtime=active['runtime'];env=os.environ.copy()
|
|
76
|
+
env['CODEX_HOME']=str(pathlib.Path(self.runtime)/'home');env['CODEX_CLI_PATH']=self.real
|
|
77
|
+
self.child=await asyncio.create_subprocess_exec(self.real,*self.args,env=env,stdin=asyncio.subprocess.PIPE,stdout=asyncio.subprocess.PIPE,limit=64*1024*1024)
|
|
78
|
+
self.output_task=asyncio.create_task(self.output())
|
|
79
|
+
|
|
80
|
+
async def output(self):
|
|
81
|
+
while line:=await self.child.stdout.readline():
|
|
82
|
+
value=json.loads(line)
|
|
83
|
+
if 'id' in value and ('result' in value or 'error' in value):
|
|
84
|
+
self.pending.discard(str(value['id']))
|
|
85
|
+
if 'result' in value and isinstance(value['result'],dict) and 'userAgent' in value['result']: self.initialized=True
|
|
86
|
+
method=value.get('method');params=value.get('params') or {}
|
|
87
|
+
if method=='turn/started': self.active_turns.add(params['threadId'])
|
|
88
|
+
if method=='turn/completed': self.active_turns.discard(params['threadId'])
|
|
89
|
+
if method=='thread/status/changed':
|
|
90
|
+
if params.get('status',{}).get('type')=='active':self.active_turns.add(params['threadId'])
|
|
91
|
+
elif params.get('status',{}).get('type') in {'idle','notLoaded','systemError'}:self.active_turns.discard(params['threadId'])
|
|
92
|
+
sys.stdout.buffer.write(line);sys.stdout.buffer.flush()
|
|
93
|
+
|
|
94
|
+
async def stop_child(self):
|
|
95
|
+
if self.child and self.child.returncode is None:
|
|
96
|
+
self.child.terminate()
|
|
97
|
+
try: await asyncio.wait_for(self.child.wait(),5)
|
|
98
|
+
except asyncio.TimeoutError:
|
|
99
|
+
# Never escalate to killing a child whose stop was not confirmed.
|
|
100
|
+
raise PackError('Owned backend did not stop; no storage switch committed')
|
|
101
|
+
if self.output_task: await self.output_task
|
|
102
|
+
|
|
103
|
+
def status(self):
|
|
104
|
+
return {'pid':os.getpid(),'childPid':self.child.pid if self.child else None,'desktopPid':os.getppid(),'runtime':self.runtime,
|
|
105
|
+
'quiesced':self.quiesced,'initialized':self.initialized,
|
|
106
|
+
'activeTurns':sorted(self.active_turns),'pendingRequests':len(self.pending)}
|
|
107
|
+
|
|
108
|
+
async def control(self,reader,writer):
|
|
109
|
+
try:
|
|
110
|
+
message=json.loads(await asyncio.wait_for(reader.readline(),5))
|
|
111
|
+
if not secrets.compare_digest(message.get('token',''),self.token): raise PackError('Live bridge authentication failed')
|
|
112
|
+
op=message.get('operation')
|
|
113
|
+
if op=='quiesce':
|
|
114
|
+
if self.active_turns: raise PackError('Outgoing agents have active turns. Wait for their current turns; Codex can stay open.')
|
|
115
|
+
if self.pending: raise PackError('Backend requests are in flight. Retry the switch; Codex can stay open.')
|
|
116
|
+
self.quiesced=True
|
|
117
|
+
await self.stop_child()
|
|
118
|
+
elif op=='resume':
|
|
119
|
+
if self.quiesced: raise PackError('Use the desktop reconnect action to resume a quiesced bridge')
|
|
120
|
+
elif op!='status': raise PackError('Unknown live bridge operation')
|
|
121
|
+
result=self.status()
|
|
122
|
+
except Exception as exc: result={'error':str(exc)}
|
|
123
|
+
writer.write(canonical(result)+b'\n');await writer.drain();writer.close();await writer.wait_closed()
|
|
124
|
+
|
|
125
|
+
async def run(self):
|
|
126
|
+
task=asyncio.current_task();loop=asyncio.get_running_loop()
|
|
127
|
+
loop.add_signal_handler(signal.SIGTERM,task.cancel)
|
|
128
|
+
loop.add_signal_handler(signal.SIGINT,task.cancel)
|
|
129
|
+
if self.path.exists():
|
|
130
|
+
previous=available(self.lib)
|
|
131
|
+
if previous: raise PackError('A managed desktop already owns this bridge')
|
|
132
|
+
if not self.path.is_socket() or self.path.stat().st_uid!=os.getuid(): raise PackError('Unowned socket path')
|
|
133
|
+
self.path.unlink()
|
|
134
|
+
server=await asyncio.start_unix_server(self.control,path=str(self.path),limit=1024*1024)
|
|
135
|
+
self.path.chmod(0o600)
|
|
136
|
+
atomic_json(self.lib.root/'live-bridge.json',{'pid':os.getpid(),'socket':str(self.path),'token':self.token})
|
|
137
|
+
try:
|
|
138
|
+
await self.start_child()
|
|
139
|
+
reader=asyncio.StreamReader(limit=64*1024*1024)
|
|
140
|
+
protocol=asyncio.StreamReaderProtocol(reader)
|
|
141
|
+
await asyncio.get_running_loop().connect_read_pipe(lambda:protocol,sys.stdin.buffer)
|
|
142
|
+
while line:=await reader.readline():
|
|
143
|
+
value=json.loads(line)
|
|
144
|
+
if self.quiesced:
|
|
145
|
+
# No new model work may start between quiesce and reconnect.
|
|
146
|
+
if 'id' in value:
|
|
147
|
+
sys.stdout.buffer.write(canonical({'id':value['id'],'error':{'code':-32000,'message':'Pack switch in progress; reconnecting backend'}})+b'\n');sys.stdout.buffer.flush()
|
|
148
|
+
continue
|
|
149
|
+
if 'id' in value and 'method' in value: self.pending.add(str(value['id']))
|
|
150
|
+
self.child.stdin.write(line);await self.child.stdin.drain()
|
|
151
|
+
finally:
|
|
152
|
+
await self.stop_child();server.close();await server.wait_closed()
|
|
153
|
+
record=read_json(self.lib.root/'live-bridge.json',{})
|
|
154
|
+
if record.get('pid')==os.getpid():
|
|
155
|
+
(self.lib.root/'live-bridge.json').unlink(missing_ok=True);self.path.unlink(missing_ok=True)
|
|
156
|
+
|
|
157
|
+
def proxy_main():
|
|
158
|
+
lib=Library(os.environ['CODEX_PACK_HOME']);real=os.environ['CODEX_PACK_REAL_CLI']
|
|
159
|
+
args=sys.argv[1:]
|
|
160
|
+
if 'app-server' not in args: os.execv(real,[real,*args])
|
|
161
|
+
try: asyncio.run(BackendProxy(lib,real,args).run())
|
|
162
|
+
except asyncio.CancelledError: return 0
|
|
163
|
+
except Exception as exc:
|
|
164
|
+
print('topolo-agent-pack backend: '+str(exc),file=sys.stderr);return 2
|
|
165
|
+
return 0
|
|
166
|
+
|
|
167
|
+
class WebSocket:
|
|
168
|
+
"""Small masked RFC6455 client for the owned Electron debug endpoint."""
|
|
169
|
+
def __init__(self,url):
|
|
170
|
+
uri=urllib.parse.urlsplit(url)
|
|
171
|
+
if uri.scheme!='ws' or uri.hostname not in {'127.0.0.1','localhost'}: raise PackError('Debug endpoint must be local')
|
|
172
|
+
self.socket=socket.create_connection((uri.hostname,uri.port),timeout=20);self.buffer=b''
|
|
173
|
+
key=base64.b64encode(os.urandom(16)).decode()
|
|
174
|
+
headers=f'GET {uri.path} HTTP/1.1\r\nHost: {uri.hostname}:{uri.port}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n'
|
|
175
|
+
self.socket.sendall(headers.encode())
|
|
176
|
+
while b'\r\n\r\n' not in self.buffer:
|
|
177
|
+
chunk=self.socket.recv(65536)
|
|
178
|
+
if not chunk:self.socket.close();raise PackError('Desktop debug handshake closed')
|
|
179
|
+
self.buffer+=chunk
|
|
180
|
+
if len(self.buffer)>65536:self.socket.close();raise PackError('Oversized debug handshake')
|
|
181
|
+
header,self.buffer=self.buffer.split(b'\r\n\r\n',1)
|
|
182
|
+
accept=base64.b64encode(hashlib.sha1((key+'258EAFA5-E914-47DA-95CA-C5AB0DC85B11').encode()).digest())
|
|
183
|
+
if not header.startswith(b'HTTP/1.1 101') or accept not in header: self.socket.close();raise PackError('Debug WebSocket handshake rejected')
|
|
184
|
+
self.seq=0
|
|
185
|
+
def read(self,n):
|
|
186
|
+
while len(self.buffer)<n:
|
|
187
|
+
chunk=self.socket.recv(max(65536,n-len(self.buffer)))
|
|
188
|
+
if not chunk: raise PackError('Desktop debug connection closed')
|
|
189
|
+
self.buffer+=chunk
|
|
190
|
+
result,self.buffer=self.buffer[:n],self.buffer[n:];return result
|
|
191
|
+
def send(self,payload,opcode=1):
|
|
192
|
+
mask=os.urandom(4);size=len(payload)
|
|
193
|
+
prefix=bytes([0x80|opcode,0x80|size]) if size<126 else bytes([0x80|opcode,0x80|126])+struct.pack('!H',size) if size<65536 else bytes([0x80|opcode,0x80|127])+struct.pack('!Q',size)
|
|
194
|
+
self.socket.sendall(prefix+mask+bytes(v^mask[i%4] for i,v in enumerate(payload)))
|
|
195
|
+
def receive(self):
|
|
196
|
+
pieces=[]
|
|
197
|
+
while True:
|
|
198
|
+
a,b=self.read(2);size=b&127
|
|
199
|
+
if size==126:size=struct.unpack('!H',self.read(2))[0]
|
|
200
|
+
elif size==127:size=struct.unpack('!Q',self.read(8))[0]
|
|
201
|
+
if size>64*1024*1024: raise PackError('Oversized debug reply')
|
|
202
|
+
mask=self.read(4) if b&128 else None;payload=self.read(size)
|
|
203
|
+
if mask:payload=bytes(v^mask[i%4] for i,v in enumerate(payload))
|
|
204
|
+
op=a&15
|
|
205
|
+
if op==8: raise PackError('Desktop debug connection closed')
|
|
206
|
+
if op==9:self.send(payload,10);continue
|
|
207
|
+
if op==10:continue
|
|
208
|
+
pieces.append(payload)
|
|
209
|
+
if a&128:return json.loads(b''.join(pieces))
|
|
210
|
+
def evaluate(self,expression):
|
|
211
|
+
self.seq+=1;self.send(canonical({'id':self.seq,'method':'Runtime.evaluate','params':{'expression':expression,'awaitPromise':True,'returnByValue':True}}))
|
|
212
|
+
while True:
|
|
213
|
+
reply=self.receive()
|
|
214
|
+
if reply.get('id')!=self.seq:continue
|
|
215
|
+
if 'error' in reply or reply.get('result',{}).get('exceptionDetails'): raise PackError('Desktop bridge expression failed: '+str(reply.get('error') or reply['result']['exceptionDetails'].get('text')))
|
|
216
|
+
return reply['result']['result'].get('value')
|
|
217
|
+
def close(self):self.socket.close()
|
|
218
|
+
|
|
219
|
+
def renderer(lib):
|
|
220
|
+
file=lib.root/'desktop-data'/'DevToolsActivePort'
|
|
221
|
+
if not file.exists(): raise PackError('Managed window has no reconnect bridge; launch it through the updated topolo-agent-pack command')
|
|
222
|
+
port=int(file.read_text().splitlines()[0])
|
|
223
|
+
opener=urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
|
224
|
+
with opener.open('http://127.0.0.1:'+str(port)+'/json/list',timeout=5) as response:targets=json.load(response)
|
|
225
|
+
for target in targets:
|
|
226
|
+
if target['type']=='page' and target.get('url','').startswith(('app://','file://')):
|
|
227
|
+
ws=WebSocket(target['webSocketDebuggerUrl'])
|
|
228
|
+
if ws.evaluate("typeof electronBridge !== 'undefined' && !!document.querySelector('[aria-label=\"App navigation\"]') && !!document.querySelector('[aria-label^=\"Switch mode\"]')"):
|
|
229
|
+
return ws,target['id']
|
|
230
|
+
ws.close()
|
|
231
|
+
raise PackError('Managed primary Codex window not found')
|
|
232
|
+
|
|
233
|
+
def fetch(ws,method,body):
|
|
234
|
+
request_id='topolo-agent-pack-'+uuid.uuid4().hex
|
|
235
|
+
message={'type':'fetch','requestId':request_id,'url':'vscode://codex/'+method,'method':'POST','body':json.dumps(body),'headers':{}}
|
|
236
|
+
expression='''new Promise((resolve,reject)=>{const id=ID;const timer=setTimeout(()=>{window.removeEventListener('message',handler);reject(new Error('Desktop state request timed out'))},10000);function handler(event){const value=event.data;if(value?.type!=='fetch-response'||value.requestId!==id)return;clearTimeout(timer);window.removeEventListener('message',handler);if(value.responseType==='error')reject(new Error(value.error));else resolve(JSON.parse(value.bodyJsonString))}window.addEventListener('message',handler);electronBridge.sendMessageFromView(MESSAGE).catch(reject)})'''.replace('ID',json.dumps(request_id),1).replace('MESSAGE',json.dumps(message),1)
|
|
237
|
+
return ws.evaluate(expression)
|
|
238
|
+
|
|
239
|
+
def adapter(app='/Applications/ChatGPT.app'):
|
|
240
|
+
# Resolve the app's exported host capability from its installed assets.
|
|
241
|
+
# Refuse changed layouts instead of guessing a private versioned symbol.
|
|
242
|
+
with (pathlib.Path(app)/'Contents/Resources/app.asar').open('rb') as stream:
|
|
243
|
+
header=struct.unpack('<4I',stream.read(16));tree=json.loads(stream.read(header[3]));base=8+header[1]
|
|
244
|
+
assets=tree['files']['webview']['files']['assets']['files']
|
|
245
|
+
for name,entry in assets.items():
|
|
246
|
+
if not name.startswith('app-initial-') or not name.endswith('.js'):continue
|
|
247
|
+
stream.seek(base+int(entry['offset']));source=stream.read(entry['size']).decode()
|
|
248
|
+
match=re.search(r'let ([\w$]+)=([\w$]+)\?\.localThreadCatalog',source)
|
|
249
|
+
if not match:continue
|
|
250
|
+
symbol=match[2]
|
|
251
|
+
for clause in re.finditer(r'import\{([^;]+?)\}from"([^\"]+)";',source[:150000]):
|
|
252
|
+
export=re.search(r'(?:^|,)([\w$]+) as '+re.escape(symbol)+r'(?:,|$)',clause[1])
|
|
253
|
+
if export:return {'module':'/assets/'+clause[2].removeprefix('./'),'export':export[1]}
|
|
254
|
+
raise PackError('This desktop build has no recognized catalog bridge; no switch started')
|
|
255
|
+
|
|
256
|
+
def thread_ids(runtime):
|
|
257
|
+
connection=sqlite3.connect('file:'+str(pathlib.Path(runtime)/'home/state_5.sqlite')+'?mode=ro',uri=True)
|
|
258
|
+
try:return [r[0] for r in connection.execute('SELECT id FROM threads WHERE archived=0')]
|
|
259
|
+
finally:connection.close()
|
|
260
|
+
|
|
261
|
+
def rendered_threads(ws):
|
|
262
|
+
return ws.evaluate('Array.from(document.querySelectorAll("[data-app-action-sidebar-thread-row][data-app-action-sidebar-thread-host-id=local]")).map(e=>({id:e.getAttribute("data-app-action-sidebar-thread-id").replace(/^local:/,""),title:e.getAttribute("data-app-action-sidebar-thread-title")}))')
|
|
263
|
+
|
|
264
|
+
def checkpoint_ui(lib,runtime):
|
|
265
|
+
ws,_=renderer(lib)
|
|
266
|
+
try:
|
|
267
|
+
path=pathlib.Path(runtime)/'home/.codex-global-state.json';gs=read_json(path)
|
|
268
|
+
for key in list(gs):
|
|
269
|
+
value=fetch(ws,'get-global-state',{'key':key}).get('value')
|
|
270
|
+
if key=='electron-persisted-atom-state':
|
|
271
|
+
value={k:(value or {}).get(k,v) for k,v in gs[key].items()}
|
|
272
|
+
if value is not None:gs[key]=value
|
|
273
|
+
atomic_json(path,gs)
|
|
274
|
+
selected=ws.evaluate('document.querySelector("[data-app-action-sidebar-thread-row][data-app-action-sidebar-thread-selected=true]")?.getAttribute("data-app-action-sidebar-thread-id")?.replace(/^local:/,"") ?? null')
|
|
275
|
+
atomic_json(pathlib.Path(runtime)/'desktop-view.json',{'threadId':selected})
|
|
276
|
+
finally:ws.close()
|
|
277
|
+
|
|
278
|
+
def reconcile(ws,capability,incoming,outgoing):
|
|
279
|
+
params=json.dumps({'adapter':capability,'incoming':incoming,'outgoing':outgoing})
|
|
280
|
+
return ws.evaluate('''(async()=>{const p=PARAMS;const c=(await import(p.adapter.module))[p.adapter.export]?.localThreadCatalog;if(!c)throw new Error('Catalog capability unavailable');for(const threadId of p.outgoing)await c.removeMissingEntry({hostId:'local',threadId});for(const threadId of p.incoming)await c.notifyThread({hostId:'local',threadId},'upsert');await c.invalidateSource('local');await c.requestSync(['local'],'immediate');let proof;const deadline=Date.now()+15000;do{const entries=await c.readEntries(p.incoming.map(threadId=>({hostId:'local',threadId})));const missing=await c.readEntries(p.outgoing.map(threadId=>({hostId:'local',threadId})));proof={incoming:entries.map(e=>e.threadId),titles:entries.map(e=>e.displayTitle),outgoing:missing.map(e=>e.threadId),count:await c.readThreadCount('local')};if(proof.incoming.length===p.incoming.length&&!proof.outgoing.length&&proof.count===p.incoming.length)return proof;await new Promise(r=>setTimeout(r,50))}while(Date.now()<deadline);return proof})()'''.replace('PARAMS',params,1))
|
|
281
|
+
|
|
282
|
+
def reconnect(lib,runtime,previous_runtime=None,app='/Applications/ChatGPT.app'):
|
|
283
|
+
ws,target_id=renderer(lib)
|
|
284
|
+
try:
|
|
285
|
+
current_bridge=available(lib)
|
|
286
|
+
if current_bridge and not current_bridge['quiesced']:quiesce(lib)
|
|
287
|
+
capability=adapter(app);bind(lib,runtime)
|
|
288
|
+
gs=read_json(pathlib.Path(runtime)/'home'/'.codex-global-state.json')
|
|
289
|
+
current=fetch(ws,'get-global-state',{'key':'electron-persisted-atom-state'}).get('value') or {}
|
|
290
|
+
updates={**gs,'electron-persisted-atom-state':{**current,**gs.get('electron-persisted-atom-state',{})}}
|
|
291
|
+
for key,value in updates.items():
|
|
292
|
+
result=fetch(ws,'set-global-state',{'key':key,'value':value})
|
|
293
|
+
if not result.get('success'): raise PackError('Desktop refused state update: '+key)
|
|
294
|
+
ws.evaluate('electronBridge.sendMessageFromView({type:"codex-app-server-restart",hostId:"local",intent:"reconnect",killCodexProcess:false}).then(()=>true)')
|
|
295
|
+
incoming=thread_ids(runtime);outgoing=sorted(set(thread_ids(previous_runtime) if previous_runtime else [])-set(incoming))
|
|
296
|
+
deadline=time.monotonic()+30
|
|
297
|
+
while time.monotonic()<deadline:
|
|
298
|
+
bridge=available(lib)
|
|
299
|
+
if bridge and not bridge['quiesced'] and bridge['runtime']==str(runtime) and bridge['initialized']:break
|
|
300
|
+
time.sleep(0.05)
|
|
301
|
+
else:raise PackError('Managed backend did not reconnect; run topolo-agent-pack recover')
|
|
302
|
+
proof=reconcile(ws,capability,incoming,outgoing)
|
|
303
|
+
if set(proof['incoming'])!=set(incoming) or proof['outgoing'] or proof['count']!=len(incoming):raise PackError('Sidebar catalog did not match selected runtime; run recover')
|
|
304
|
+
# Soft reload clears thread/composer caches while retaining this exact
|
|
305
|
+
# renderer target and native window. No window or desktop process quits.
|
|
306
|
+
view=read_json(pathlib.Path(runtime)/'desktop-view.json',{})
|
|
307
|
+
selected=view.get('threadId') if view.get('threadId') in incoming else None
|
|
308
|
+
navigation={'path':'/local/'+selected if selected else '/','replace':True,'persistForReload':True}
|
|
309
|
+
ws.evaluate('(async()=>{const h=(await import('+json.dumps(capability['module'])+'))['+json.dumps(capability['export'])+'];const ok=await h.windowNavigation.navigateInOwnerWindow('+json.dumps(navigation)+');if(!ok)throw new Error("Owner window navigation unavailable");return true})()')
|
|
310
|
+
ws.evaluate('setTimeout(()=>location.reload(),0);true')
|
|
311
|
+
ws.close()
|
|
312
|
+
sidebar=[]
|
|
313
|
+
while time.monotonic()<deadline:
|
|
314
|
+
try:
|
|
315
|
+
ws,confirmed=renderer(lib)
|
|
316
|
+
if confirmed!=target_id:raise PackError('Desktop switched renderer targets instead of retaining the window')
|
|
317
|
+
rows=rendered_threads(ws);sidebar=[r['title'] for r in rows]
|
|
318
|
+
text=ws.evaluate('document.body.innerText')
|
|
319
|
+
shown={r['id'] for r in rows}
|
|
320
|
+
if text and shown and shown<=set(incoming) and not shown & set(outgoing):break
|
|
321
|
+
ws.close();time.sleep(0.05)
|
|
322
|
+
except (OSError,PackError):time.sleep(0.05)
|
|
323
|
+
else:raise PackError('Desktop did not render after reconnect; run recover')
|
|
324
|
+
return {'state':'same-window-verified','app':str(app),'targetId':target_id,'desktopPid':bridge['desktopPid'],'frontendVerified':True,'windowClosed':False,'catalog':proof,'renderedTitles':sidebar,'renderedThreadIds':sorted(shown),'selectedThreadId':selected}
|
|
325
|
+
finally:ws.close()
|
|
326
|
+
|
|
327
|
+
def prepare(lib,real):
|
|
328
|
+
home=lib.root/'desktop-home'
|
|
329
|
+
if home.exists() and not (lib.root/'desktop-layout.json').exists():raise PackError('Existing desktop-home is unowned; choose a separate pack library')
|
|
330
|
+
folder=lib.root/'launchers';folder.mkdir(exist_ok=True,mode=0o700)
|
|
331
|
+
executable=folder/'topolo-agent-pack-backend'
|
|
332
|
+
source=pathlib.Path(__file__).resolve().parents[1]
|
|
333
|
+
executable.write_text('#!'+sys.executable+'\nimport sys\nsys.path.insert(0,'+repr(str(source))+')\nfrom topolo_agent_pack.live import proxy_main\nraise SystemExit(proxy_main())\n');executable.chmod(0o700)
|
|
334
|
+
home.mkdir(exist_ok=True,mode=0o700)
|
|
335
|
+
atomic_json(lib.root/'desktop-layout.json',{'owner':'topolo-agent-pack','schemaVersion':1})
|
|
336
|
+
bind(lib,lib.active()['runtime'])
|
|
337
|
+
if not (home/'.codex-global-state.json').exists():atomic_json(home/'.codex-global-state.json',read_json(pathlib.Path(lib.active()['runtime'])/'home/.codex-global-state.json'))
|
|
338
|
+
return executable,home
|
|
339
|
+
|
|
340
|
+
def bind(lib,runtime):
|
|
341
|
+
if not (lib.root/'desktop-layout.json').exists():return
|
|
342
|
+
if read_json(lib.root/'desktop-layout.json').get('owner') not in {'topolo-agent-pack','codex-pack'}:raise PackError('Unowned desktop layout')
|
|
343
|
+
target=lib.root/'selected-runtime';temporary=lib.root/('.selected-'+uuid.uuid4().hex)
|
|
344
|
+
if target.exists() and not target.is_symlink():raise PackError('Selected-runtime path is unowned')
|
|
345
|
+
temporary.symlink_to(pathlib.Path(runtime));os.replace(temporary,target)
|
|
346
|
+
home=lib.root/'desktop-home'
|
|
347
|
+
paths=['state_5.sqlite','thread_history_1.sqlite','goals_1.sqlite','auth.json','config.toml','AGENTS.md','sessions','archived_sessions','skills','agents','worktrees']
|
|
348
|
+
for name in ['sessions','archived_sessions','skills','agents','worktrees']:
|
|
349
|
+
(pathlib.Path(runtime)/'home'/name).mkdir(exist_ok=True,mode=0o700)
|
|
350
|
+
for name in paths:
|
|
351
|
+
dest=home/name
|
|
352
|
+
expected=pathlib.Path('..')/'selected-runtime'/'home'/name
|
|
353
|
+
if dest.is_symlink():
|
|
354
|
+
if os.readlink(dest)!=str(expected):raise PackError('Unowned desktop route: '+name)
|
|
355
|
+
continue
|
|
356
|
+
if dest.exists():
|
|
357
|
+
# Earlier manager prototypes used ordinary control-home files.
|
|
358
|
+
# Keep them for inspection when migrating to the routed home.
|
|
359
|
+
backup=home/('legacy-'+name)
|
|
360
|
+
if backup.exists():raise PackError('Cannot preserve existing desktop-home file: '+name)
|
|
361
|
+
dest.rename(backup)
|
|
362
|
+
dest.symlink_to(expected)
|