@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.
Files changed (119) hide show
  1. package/README.md +62 -0
  2. package/agent-packs/README.md +341 -0
  3. package/agent-packs/pyproject.toml +16 -0
  4. package/agent-packs/requirements-test.txt +1 -0
  5. package/agent-packs/src/topolo_agent_pack/__init__.py +1 -0
  6. package/agent-packs/src/topolo_agent_pack/blueprint.py +57 -0
  7. package/agent-packs/src/topolo_agent_pack/cli.py +416 -0
  8. package/agent-packs/src/topolo_agent_pack/fresh.py +158 -0
  9. package/agent-packs/src/topolo_agent_pack/live.py +362 -0
  10. package/agent-packs/src/topolo_agent_pack/native.py +645 -0
  11. package/agent-packs/src/topolo_agent_pack/storage.py +271 -0
  12. package/agent-packs/src/topolo_agent_pack/topolo.py +321 -0
  13. package/agent-packs/templates/career/AGENTS.md +17 -0
  14. package/agent-packs/templates/career/README.md +14 -0
  15. package/agent-packs/templates/career/agents/intakes/career-intake/AGENTS.md +21 -0
  16. package/agent-packs/templates/career/agents/specialists/application-preparation/AGENTS.md +21 -0
  17. package/agent-packs/templates/career/agents/specialists/application-tracker/AGENTS.md +21 -0
  18. package/agent-packs/templates/career/agents/specialists/interview-coach/AGENTS.md +21 -0
  19. package/agent-packs/templates/career/agents/specialists/opportunity-research/AGENTS.md +21 -0
  20. package/agent-packs/templates/career/agents/specialists/profile-cv/AGENTS.md +21 -0
  21. package/agent-packs/templates/career/pack.json +124 -0
  22. package/agent-packs/templates/career/shared/PACK.md +22 -0
  23. package/agent-packs/templates/career/shared/TOPOLO.md +13 -0
  24. package/agent-packs/templates/career/shared/WORKFLOW.md +13 -0
  25. package/agent-packs/templates/career/skills/topolo/SKILL.md +305 -0
  26. package/agent-packs/templates/clients/AGENTS.md +17 -0
  27. package/agent-packs/templates/clients/README.md +14 -0
  28. package/agent-packs/templates/clients/agents/intakes/client-intake/AGENTS.md +21 -0
  29. package/agent-packs/templates/clients/agents/specialists/acceptance-review/AGENTS.md +21 -0
  30. package/agent-packs/templates/clients/agents/specialists/delivery-planner/AGENTS.md +21 -0
  31. package/agent-packs/templates/clients/agents/specialists/discovery/AGENTS.md +21 -0
  32. package/agent-packs/templates/clients/agents/specialists/proposal-writer/AGENTS.md +21 -0
  33. package/agent-packs/templates/clients/agents/specialists/solution-design/AGENTS.md +21 -0
  34. package/agent-packs/templates/clients/pack.json +126 -0
  35. package/agent-packs/templates/clients/shared/PACK.md +22 -0
  36. package/agent-packs/templates/clients/shared/TOPOLO.md +13 -0
  37. package/agent-packs/templates/clients/shared/WORKFLOW.md +13 -0
  38. package/agent-packs/templates/clients/skills/topolo/SKILL.md +305 -0
  39. package/agent-packs/templates/creative/AGENTS.md +17 -0
  40. package/agent-packs/templates/creative/README.md +14 -0
  41. package/agent-packs/templates/creative/agents/intakes/creative-director/AGENTS.md +21 -0
  42. package/agent-packs/templates/creative/agents/specialists/brand-copy/AGENTS.md +21 -0
  43. package/agent-packs/templates/creative/agents/specialists/media-production/AGENTS.md +21 -0
  44. package/agent-packs/templates/creative/agents/specialists/quality-review/AGENTS.md +21 -0
  45. package/agent-packs/templates/creative/agents/specialists/storytelling/AGENTS.md +21 -0
  46. package/agent-packs/templates/creative/agents/specialists/visual-design/AGENTS.md +21 -0
  47. package/agent-packs/templates/creative/pack.json +128 -0
  48. package/agent-packs/templates/creative/shared/PACK.md +22 -0
  49. package/agent-packs/templates/creative/shared/TOPOLO.md +13 -0
  50. package/agent-packs/templates/creative/shared/WORKFLOW.md +13 -0
  51. package/agent-packs/templates/creative/skills/topolo/SKILL.md +305 -0
  52. package/agent-packs/templates/founder/AGENTS.md +17 -0
  53. package/agent-packs/templates/founder/README.md +14 -0
  54. package/agent-packs/templates/founder/agents/intakes/founder-intake/AGENTS.md +21 -0
  55. package/agent-packs/templates/founder/agents/specialists/commercial-modelling/AGENTS.md +21 -0
  56. package/agent-packs/templates/founder/agents/specialists/customer-research/AGENTS.md +21 -0
  57. package/agent-packs/templates/founder/agents/specialists/decision-review/AGENTS.md +21 -0
  58. package/agent-packs/templates/founder/agents/specialists/partnerships/AGENTS.md +21 -0
  59. package/agent-packs/templates/founder/agents/specialists/strategy/AGENTS.md +21 -0
  60. package/agent-packs/templates/founder/pack.json +126 -0
  61. package/agent-packs/templates/founder/shared/PACK.md +22 -0
  62. package/agent-packs/templates/founder/shared/TOPOLO.md +13 -0
  63. package/agent-packs/templates/founder/shared/WORKFLOW.md +13 -0
  64. package/agent-packs/templates/founder/skills/topolo/SKILL.md +305 -0
  65. package/agent-packs/templates/learning/AGENTS.md +17 -0
  66. package/agent-packs/templates/learning/README.md +14 -0
  67. package/agent-packs/templates/learning/agents/intakes/learning-intake/AGENTS.md +21 -0
  68. package/agent-packs/templates/learning/agents/specialists/critical-reviewer/AGENTS.md +21 -0
  69. package/agent-packs/templates/learning/agents/specialists/curriculum-designer/AGENTS.md +21 -0
  70. package/agent-packs/templates/learning/agents/specialists/knowledge-steward/AGENTS.md +21 -0
  71. package/agent-packs/templates/learning/agents/specialists/practice-coach/AGENTS.md +21 -0
  72. package/agent-packs/templates/learning/agents/specialists/tutor/AGENTS.md +21 -0
  73. package/agent-packs/templates/learning/pack.json +124 -0
  74. package/agent-packs/templates/learning/shared/PACK.md +22 -0
  75. package/agent-packs/templates/learning/shared/TOPOLO.md +13 -0
  76. package/agent-packs/templates/learning/shared/WORKFLOW.md +13 -0
  77. package/agent-packs/templates/learning/skills/topolo/SKILL.md +305 -0
  78. package/agent-packs/templates/personal/AGENTS.md +17 -0
  79. package/agent-packs/templates/personal/README.md +14 -0
  80. package/agent-packs/templates/personal/agents/intakes/personal-intake/AGENTS.md +21 -0
  81. package/agent-packs/templates/personal/agents/specialists/correspondence/AGENTS.md +21 -0
  82. package/agent-packs/templates/personal/agents/specialists/document-assistant/AGENTS.md +21 -0
  83. package/agent-packs/templates/personal/agents/specialists/planning/AGENTS.md +21 -0
  84. package/agent-packs/templates/personal/agents/specialists/research/AGENTS.md +21 -0
  85. package/agent-packs/templates/personal/agents/specialists/travel-logistics/AGENTS.md +21 -0
  86. package/agent-packs/templates/personal/pack.json +125 -0
  87. package/agent-packs/templates/personal/shared/PACK.md +22 -0
  88. package/agent-packs/templates/personal/shared/TOPOLO.md +13 -0
  89. package/agent-packs/templates/personal/shared/WORKFLOW.md +13 -0
  90. package/agent-packs/templates/personal/skills/topolo/SKILL.md +305 -0
  91. package/agent-packs/templates/research/AGENTS.md +17 -0
  92. package/agent-packs/templates/research/README.md +14 -0
  93. package/agent-packs/templates/research/agents/intakes/research-intake/AGENTS.md +21 -0
  94. package/agent-packs/templates/research/agents/specialists/adversarial-review/AGENTS.md +21 -0
  95. package/agent-packs/templates/research/agents/specialists/data-analysis/AGENTS.md +21 -0
  96. package/agent-packs/templates/research/agents/specialists/source-research/AGENTS.md +21 -0
  97. package/agent-packs/templates/research/agents/specialists/synthesis/AGENTS.md +21 -0
  98. package/agent-packs/templates/research/agents/specialists/technical-research/AGENTS.md +21 -0
  99. package/agent-packs/templates/research/pack.json +124 -0
  100. package/agent-packs/templates/research/shared/PACK.md +22 -0
  101. package/agent-packs/templates/research/shared/TOPOLO.md +13 -0
  102. package/agent-packs/templates/research/shared/WORKFLOW.md +13 -0
  103. package/agent-packs/templates/research/skills/topolo/SKILL.md +305 -0
  104. package/agent-packs/templates/ventures/AGENTS.md +17 -0
  105. package/agent-packs/templates/ventures/README.md +14 -0
  106. package/agent-packs/templates/ventures/agents/intakes/venture-intake/AGENTS.md +21 -0
  107. package/agent-packs/templates/ventures/agents/specialists/customer-discovery/AGENTS.md +21 -0
  108. package/agent-packs/templates/ventures/agents/specialists/economics/AGENTS.md +21 -0
  109. package/agent-packs/templates/ventures/agents/specialists/experiment-design/AGENTS.md +21 -0
  110. package/agent-packs/templates/ventures/agents/specialists/market-research/AGENTS.md +21 -0
  111. package/agent-packs/templates/ventures/agents/specialists/product-concept/AGENTS.md +21 -0
  112. package/agent-packs/templates/ventures/pack.json +126 -0
  113. package/agent-packs/templates/ventures/shared/PACK.md +22 -0
  114. package/agent-packs/templates/ventures/shared/TOPOLO.md +13 -0
  115. package/agent-packs/templates/ventures/shared/WORKFLOW.md +13 -0
  116. package/agent-packs/templates/ventures/skills/topolo/SKILL.md +305 -0
  117. package/agent-packs/topolo-agent-pack +8 -0
  118. package/dist/index.js +119 -51
  119. 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)