@topolo/cli 0.12.33 → 0.13.1

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 (123) hide show
  1. package/README.md +45 -0
  2. package/agent-packs/README.md +495 -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/claude.py +265 -0
  8. package/agent-packs/src/topolo_agent_pack/cli.py +443 -0
  9. package/agent-packs/src/topolo_agent_pack/execution.py +154 -0
  10. package/agent-packs/src/topolo_agent_pack/fresh.py +158 -0
  11. package/agent-packs/src/topolo_agent_pack/live.py +403 -0
  12. package/agent-packs/src/topolo_agent_pack/native.py +645 -0
  13. package/agent-packs/src/topolo_agent_pack/policy.py +92 -0
  14. package/agent-packs/src/topolo_agent_pack/storage.py +271 -0
  15. package/agent-packs/src/topolo_agent_pack/topolo.py +321 -0
  16. package/agent-packs/templates/career/AGENTS.md +17 -0
  17. package/agent-packs/templates/career/README.md +14 -0
  18. package/agent-packs/templates/career/agents/intakes/career-intake/AGENTS.md +21 -0
  19. package/agent-packs/templates/career/agents/specialists/application-preparation/AGENTS.md +21 -0
  20. package/agent-packs/templates/career/agents/specialists/application-tracker/AGENTS.md +21 -0
  21. package/agent-packs/templates/career/agents/specialists/interview-coach/AGENTS.md +21 -0
  22. package/agent-packs/templates/career/agents/specialists/opportunity-research/AGENTS.md +21 -0
  23. package/agent-packs/templates/career/agents/specialists/profile-cv/AGENTS.md +21 -0
  24. package/agent-packs/templates/career/pack.json +124 -0
  25. package/agent-packs/templates/career/shared/PACK.md +22 -0
  26. package/agent-packs/templates/career/shared/TOPOLO.md +13 -0
  27. package/agent-packs/templates/career/shared/WORKFLOW.md +13 -0
  28. package/agent-packs/templates/career/skills/topolo/SKILL.md +305 -0
  29. package/agent-packs/templates/clients/AGENTS.md +17 -0
  30. package/agent-packs/templates/clients/README.md +14 -0
  31. package/agent-packs/templates/clients/agents/intakes/client-intake/AGENTS.md +21 -0
  32. package/agent-packs/templates/clients/agents/specialists/acceptance-review/AGENTS.md +21 -0
  33. package/agent-packs/templates/clients/agents/specialists/delivery-planner/AGENTS.md +21 -0
  34. package/agent-packs/templates/clients/agents/specialists/discovery/AGENTS.md +21 -0
  35. package/agent-packs/templates/clients/agents/specialists/proposal-writer/AGENTS.md +21 -0
  36. package/agent-packs/templates/clients/agents/specialists/solution-design/AGENTS.md +21 -0
  37. package/agent-packs/templates/clients/pack.json +126 -0
  38. package/agent-packs/templates/clients/shared/PACK.md +22 -0
  39. package/agent-packs/templates/clients/shared/TOPOLO.md +13 -0
  40. package/agent-packs/templates/clients/shared/WORKFLOW.md +13 -0
  41. package/agent-packs/templates/clients/skills/topolo/SKILL.md +305 -0
  42. package/agent-packs/templates/creative/AGENTS.md +17 -0
  43. package/agent-packs/templates/creative/README.md +14 -0
  44. package/agent-packs/templates/creative/agents/intakes/creative-director/AGENTS.md +21 -0
  45. package/agent-packs/templates/creative/agents/specialists/brand-copy/AGENTS.md +21 -0
  46. package/agent-packs/templates/creative/agents/specialists/media-production/AGENTS.md +21 -0
  47. package/agent-packs/templates/creative/agents/specialists/quality-review/AGENTS.md +21 -0
  48. package/agent-packs/templates/creative/agents/specialists/storytelling/AGENTS.md +21 -0
  49. package/agent-packs/templates/creative/agents/specialists/visual-design/AGENTS.md +21 -0
  50. package/agent-packs/templates/creative/pack.json +128 -0
  51. package/agent-packs/templates/creative/shared/PACK.md +22 -0
  52. package/agent-packs/templates/creative/shared/TOPOLO.md +13 -0
  53. package/agent-packs/templates/creative/shared/WORKFLOW.md +13 -0
  54. package/agent-packs/templates/creative/skills/topolo/SKILL.md +305 -0
  55. package/agent-packs/templates/founder/AGENTS.md +17 -0
  56. package/agent-packs/templates/founder/README.md +14 -0
  57. package/agent-packs/templates/founder/agents/intakes/founder-intake/AGENTS.md +21 -0
  58. package/agent-packs/templates/founder/agents/specialists/commercial-modelling/AGENTS.md +21 -0
  59. package/agent-packs/templates/founder/agents/specialists/customer-research/AGENTS.md +21 -0
  60. package/agent-packs/templates/founder/agents/specialists/decision-review/AGENTS.md +21 -0
  61. package/agent-packs/templates/founder/agents/specialists/partnerships/AGENTS.md +21 -0
  62. package/agent-packs/templates/founder/agents/specialists/strategy/AGENTS.md +21 -0
  63. package/agent-packs/templates/founder/pack.json +126 -0
  64. package/agent-packs/templates/founder/shared/PACK.md +22 -0
  65. package/agent-packs/templates/founder/shared/TOPOLO.md +13 -0
  66. package/agent-packs/templates/founder/shared/WORKFLOW.md +13 -0
  67. package/agent-packs/templates/founder/skills/topolo/SKILL.md +305 -0
  68. package/agent-packs/templates/learning/AGENTS.md +17 -0
  69. package/agent-packs/templates/learning/README.md +14 -0
  70. package/agent-packs/templates/learning/agents/intakes/learning-intake/AGENTS.md +21 -0
  71. package/agent-packs/templates/learning/agents/specialists/critical-reviewer/AGENTS.md +21 -0
  72. package/agent-packs/templates/learning/agents/specialists/curriculum-designer/AGENTS.md +21 -0
  73. package/agent-packs/templates/learning/agents/specialists/knowledge-steward/AGENTS.md +21 -0
  74. package/agent-packs/templates/learning/agents/specialists/practice-coach/AGENTS.md +21 -0
  75. package/agent-packs/templates/learning/agents/specialists/tutor/AGENTS.md +21 -0
  76. package/agent-packs/templates/learning/pack.json +124 -0
  77. package/agent-packs/templates/learning/shared/PACK.md +22 -0
  78. package/agent-packs/templates/learning/shared/TOPOLO.md +13 -0
  79. package/agent-packs/templates/learning/shared/WORKFLOW.md +13 -0
  80. package/agent-packs/templates/learning/skills/topolo/SKILL.md +305 -0
  81. package/agent-packs/templates/personal/AGENTS.md +17 -0
  82. package/agent-packs/templates/personal/README.md +14 -0
  83. package/agent-packs/templates/personal/agents/intakes/personal-intake/AGENTS.md +21 -0
  84. package/agent-packs/templates/personal/agents/specialists/correspondence/AGENTS.md +21 -0
  85. package/agent-packs/templates/personal/agents/specialists/document-assistant/AGENTS.md +21 -0
  86. package/agent-packs/templates/personal/agents/specialists/planning/AGENTS.md +21 -0
  87. package/agent-packs/templates/personal/agents/specialists/research/AGENTS.md +21 -0
  88. package/agent-packs/templates/personal/agents/specialists/travel-logistics/AGENTS.md +21 -0
  89. package/agent-packs/templates/personal/pack.json +125 -0
  90. package/agent-packs/templates/personal/shared/PACK.md +22 -0
  91. package/agent-packs/templates/personal/shared/TOPOLO.md +13 -0
  92. package/agent-packs/templates/personal/shared/WORKFLOW.md +13 -0
  93. package/agent-packs/templates/personal/skills/topolo/SKILL.md +305 -0
  94. package/agent-packs/templates/research/AGENTS.md +17 -0
  95. package/agent-packs/templates/research/README.md +14 -0
  96. package/agent-packs/templates/research/agents/intakes/research-intake/AGENTS.md +21 -0
  97. package/agent-packs/templates/research/agents/specialists/adversarial-review/AGENTS.md +21 -0
  98. package/agent-packs/templates/research/agents/specialists/data-analysis/AGENTS.md +21 -0
  99. package/agent-packs/templates/research/agents/specialists/source-research/AGENTS.md +21 -0
  100. package/agent-packs/templates/research/agents/specialists/synthesis/AGENTS.md +21 -0
  101. package/agent-packs/templates/research/agents/specialists/technical-research/AGENTS.md +21 -0
  102. package/agent-packs/templates/research/pack.json +124 -0
  103. package/agent-packs/templates/research/shared/PACK.md +22 -0
  104. package/agent-packs/templates/research/shared/TOPOLO.md +13 -0
  105. package/agent-packs/templates/research/shared/WORKFLOW.md +13 -0
  106. package/agent-packs/templates/research/skills/topolo/SKILL.md +305 -0
  107. package/agent-packs/templates/ventures/AGENTS.md +17 -0
  108. package/agent-packs/templates/ventures/README.md +14 -0
  109. package/agent-packs/templates/ventures/agents/intakes/venture-intake/AGENTS.md +21 -0
  110. package/agent-packs/templates/ventures/agents/specialists/customer-discovery/AGENTS.md +21 -0
  111. package/agent-packs/templates/ventures/agents/specialists/economics/AGENTS.md +21 -0
  112. package/agent-packs/templates/ventures/agents/specialists/experiment-design/AGENTS.md +21 -0
  113. package/agent-packs/templates/ventures/agents/specialists/market-research/AGENTS.md +21 -0
  114. package/agent-packs/templates/ventures/agents/specialists/product-concept/AGENTS.md +21 -0
  115. package/agent-packs/templates/ventures/pack.json +126 -0
  116. package/agent-packs/templates/ventures/shared/PACK.md +22 -0
  117. package/agent-packs/templates/ventures/shared/TOPOLO.md +13 -0
  118. package/agent-packs/templates/ventures/shared/WORKFLOW.md +13 -0
  119. package/agent-packs/templates/ventures/skills/topolo/SKILL.md +305 -0
  120. package/agent-packs/topolo-agent-pack +8 -0
  121. package/dist/claude-pack-backend-IDQQX3DG.js +555 -0
  122. package/dist/index.js +143 -58
  123. package/package.json +7 -4
@@ -0,0 +1,158 @@
1
+ """Fresh native threads from reviewed portable context; no model calls."""
2
+ import json, os, pathlib, re, selectors, shutil, sqlite3, subprocess, tempfile, time
3
+ from . import native, __version__
4
+ from .storage import PackError, canonical, atomic_json, now, safe_id, select
5
+
6
+ class Backend:
7
+ def __init__(self,binary,home,root):
8
+ if not binary:raise PackError('Native Codex binary is required')
9
+ env=os.environ.copy();env['CODEX_HOME']=str(home)
10
+ for k in list(env):
11
+ if k.startswith('CODEX_APP_TOOLS_'):env.pop(k)
12
+ self.log=open(root/'seed-backend.log','ab')
13
+ self.proc=subprocess.Popen([str(binary),'app-server','--listen','stdio://'],cwd=root,env=env,stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=self.log,bufsize=0)
14
+ self.sel=selectors.DefaultSelector();self.sel.register(self.proc.stdout,selectors.EVENT_READ);self.seq=0;self.buffer=b''
15
+ try:
16
+ self.call('initialize',{'clientInfo':{'name':'topolo_agent_pack','version':__version__},'capabilities':{'experimentalApi':True}})
17
+ self.proc.stdin.write(canonical({'method':'initialized'})+b'\n');self.proc.stdin.flush()
18
+ except BaseException:
19
+ self.close()
20
+ raise
21
+ def call(self,method,params):
22
+ self.seq+=1;self.proc.stdin.write(canonical({'id':self.seq,'method':method,'params':params})+b'\n');self.proc.stdin.flush();end=time.monotonic()+30
23
+ while time.monotonic()<end:
24
+ while b'\n' in self.buffer:
25
+ line,self.buffer=self.buffer.split(b'\n',1)
26
+ if not line.strip():continue
27
+ value=json.loads(line)
28
+ if value.get('id')==self.seq:
29
+ if 'error' in value:raise PackError(method+': '+value['error']['message'])
30
+ return value['result']
31
+ if not self.sel.select(max(0,end-time.monotonic())):break
32
+ chunk=os.read(self.proc.stdout.fileno(),1024*1024)
33
+ if not chunk:raise PackError('Native seed backend exited')
34
+ self.buffer+=chunk
35
+ raise PackError('Native seed timed out: '+method)
36
+ def close(self):
37
+ self.proc.terminate()
38
+ try:self.proc.wait(timeout=5)
39
+ except subprocess.TimeoutExpired:self.proc.kill();self.proc.wait()
40
+ self.sel.close();self.proc.stdin.close();self.proc.stdout.close();self.log.close()
41
+ def __enter__(self):return self
42
+ def __exit__(self,*args):self.close()
43
+
44
+ BASE='Follow the current human request and applicable project instructions. Inspect context, proceed within authorized scope, respect owners and pauses, and verify outcomes. No task is assigned during initialization.\n'
45
+
46
+ def prepare(lib,source,directory,sets=None,agents=None):
47
+ m,revision=lib.load(source);chosen=select(m,sets,agents);root=pathlib.Path(directory).expanduser().resolve()
48
+ if root.exists():raise PackError('Context destination already exists; choose a new directory')
49
+ root.mkdir(parents=True,mode=0o700)
50
+ for folder in ['review','agents','shared']:(root/folder).mkdir()
51
+ common=next((d for d in m['definitions'] if d['relative']=='AGENTS.md'),None)
52
+ (root/'AGENTS.md').write_text(lib.object(common['content']).read_text() if common else BASE)
53
+ rule=re.compile(r'\b(always|never|from now on|going forward|whenever|as a rule|each time|must|instructions)\b',re.I);evidence=[]
54
+ for a in chosen:
55
+ folder=root/'agents'/a['set']/a['id'];folder.mkdir(parents=True,exist_ok=True)
56
+ (folder/'AGENTS.md').write_text('# '+a['title']+'\n\nRole: '+a['id']+' (set '+a['set']+').\nOperate within this role and route work outside its authority to the appropriate intake. Read shared operating context before work. Await a new assignment.\n')
57
+ seen=set();candidates=[];tid=a['threadId'];lineage=[]
58
+ while tid and tid not in seen:
59
+ seen.add(tid);lineage.append(tid);tid=m['threads'][tid].get('historyBase')
60
+ for tid in reversed(lineage):
61
+ with lib.object(m['threads'][tid]['rollout']).open('rb') as stream:
62
+ for line in stream:
63
+ v=json.loads(line);p=v.get('payload',{})
64
+ if v.get('type')!='response_item' or p.get('role')!='user':continue
65
+ text='\n'.join(c.get('text','') for c in p.get('content',[]) if isinstance(c,dict))
66
+ if text.lstrip().startswith('<') or '# AGENTS.md instructions' in text:continue
67
+ for paragraph in text.split('\n\n'):
68
+ if 50<len(paragraph)<2000 and rule.search(paragraph):candidates.append({'threadId':tid,'ordinal':v.get('ordinal'),'text':paragraph})
69
+ candidates=list({c['text']:c for c in candidates}.values())[-30:]
70
+ atomic_json(root/'review'/(a['set']+'--'+a['id']+'.json'),{'agent':a['id'],'candidates':candidates,'status':'source excerpts; not applied'})
71
+ evidence.append({'agent':a['id'],'sourceThreadId':a['threadId'],'candidateCount':len(candidates),'sourceWorkingDirectory':m['threads'][a['threadId']]['cwd']})
72
+ atomic_json(root/'review'/'provenance.json',{'source':source,'revision':revision,'agents':evidence,'createdAt':now()})
73
+ (root/'README.md').write_text('Edit AGENTS.md and agents/<set>/<agent-id>/AGENTS.md. Put selected portable reference documents in shared/*.md. Review excerpts under review/ and promote only reusable rules into instruction files. Review excerpts and provenance are NEVER loaded into fresh agents. Exclude old assignments, registry/status records, dispatches, leases, credentials and conversation histories. Initial role guides are scaffolds; add the necessary charters before creating a fresh pack.\n')
74
+ return {'state':'context-extracted-for-review','directory':str(root),'agents':len(chosen),'candidates':sum(e['candidateCount'] for e in evidence),'sourceRevision':revision}
75
+
76
+ def create(lib,source,name,directory,binary,sets=None,agents=None,authored=None):
77
+ safe_id(name)
78
+ if (lib.root/'packs'/name/'head.json').exists():raise PackError('Fresh destination already exists; choose a new name to preserve its work')
79
+ if lib.pending():raise PackError('Recover the pending operation first')
80
+ m,revision=authored if authored is not None else lib.load(source)
81
+ chosen=select(m,sets,agents);context=pathlib.Path(directory).expanduser().resolve();common=context/'AGENTS.md'
82
+ if not common.is_file() or common.is_symlink():raise PackError('Context requires a regular AGENTS.md')
83
+ common_text=common.read_text();selected={};presets={}
84
+ for a in chosen:
85
+ role=context/'agents'/a['set']/a['id']/'AGENTS.md'
86
+ if not role.is_file() or role.is_symlink():raise PackError('Missing regular role guide: '+str(role))
87
+ selected[(a['set'],a['id'])]=role.read_text()
88
+ preset=role.with_name('config.json')
89
+ if preset.exists():
90
+ if preset.is_symlink():raise PackError('Preset must be a regular file')
91
+ value=json.loads(preset.read_text())
92
+ if set(value)-{'model','reasoningEffort'} or not isinstance(value.get('model'),str) or value.get('reasoningEffort') not in {'none','minimal','low','medium','high','xhigh','max','ultra'}:raise PackError('Invalid role model preset: '+str(preset))
93
+ presets[(a['set'],a['id'])]=value
94
+ shared=[]
95
+ if (context/'shared').is_symlink():raise PackError('Shared context cannot be a symlink')
96
+ for f in sorted((context/'shared').rglob('*')):
97
+ if f.is_symlink():raise PackError('Context symlink is not portable: '+str(f))
98
+ if f.is_file():
99
+ if f.suffix!='.md':raise PackError('Shared starting context accepts Markdown only: '+str(f))
100
+ if f.stat().st_size>1024*1024:raise PackError('Starting reference exceeds 1 MiB: '+str(f))
101
+ shared.append((f.relative_to(context/'shared'),f.read_bytes()))
102
+ if len(common_text.encode())+sum(len(v.encode()) for v in selected.values())+sum(len(v) for _,v in shared)>16*1024*1024:raise PackError('Starting context exceeds 16 MiB')
103
+ root=pathlib.Path(tempfile.mkdtemp(prefix='.fresh-seed-',dir=lib.root));home=root/'home';home.mkdir()
104
+ try:
105
+ (home/'AGENTS.md').write_text(common_text);(home/'config.toml').write_text(''.join(k+' = '+json.dumps(v)+'\n' for k,v in m.get('config',{}).items() if k in {'model','model_reasoning_effort','personality'})+'[analytics]\nenabled=false\n')
106
+ for d in m['definitions']:
107
+ relative=pathlib.Path(d['relative'])
108
+ if not relative.parts or relative.parts[0]!='skills':continue
109
+ if relative.is_absolute() or '..' in relative.parts:raise PackError('Unsafe skill definition')
110
+ target=home/relative;target.parent.mkdir(parents=True,exist_ok=True);shutil.copyfile(lib.object(d['content']),target)
111
+ seeded=[];records=[]
112
+ with Backend(binary,home,root) as b:
113
+ for a in chosen:
114
+ workspace=root/'workspaces'/a['set']/a['id'];workspace.mkdir(parents=True)
115
+ (workspace/'AGENTS.md').write_text(common_text+'\n\n'+selected[(a['set'],a['id'])]+'\n\nStarting references are in context/. Read relevant files before work. No prior assignment or conversation is inherited.\n')
116
+ for relative,data in shared:
117
+ target=workspace/'context'/relative;target.parent.mkdir(parents=True,exist_ok=True);target.write_bytes(data)
118
+ preset=presets.get((a['set'],a['id']),{})
119
+ params={'cwd':str(workspace),'historyMode':'paginated','ephemeral':False}
120
+ if preset:params.update({'model':preset['model'],'config':{'model_reasoning_effort':preset['reasoningEffort']}})
121
+ result=b.call('thread/start',params);tid=result['thread']['id'];b.call('thread/name/set',{'threadId':tid,'name':a['title']})
122
+ if preset:b.call('thread/settings/update',{'threadId':tid,'model':preset['model'],'effort':preset['reasoningEffort']})
123
+ note='Fresh '+a['title']+'. Read AGENTS.md and relevant context/ files for operating instructions. No task is assigned. Wait for a new human request.'
124
+ b.call('thread/inject_items',{'threadId':tid,'items':[{'type':'message','role':'user','content':[{'type':'input_text','text':note}]}]});b.call('thread/unsubscribe',{'threadId':tid})
125
+ seeded.append({**a,'pack':name,'threadId':tid});records.append((tid,result['thread']['path'],note))
126
+ team='# Fresh pack team\n\nResolve peer identities here for human-authorized coordination. Every member starts with no task or claim. This roster grants no external messaging or product effects.\n\n'+'\n'.join('- '+a['set']+'/'+a['id']+' | '+a['title']+' | thread '+a['threadId'] for a in seeded)+'\n'
127
+ for a in seeded:
128
+ target=root/'workspaces'/a['set']/a['id']/'context'/'TEAM.md';target.parent.mkdir(parents=True,exist_ok=True);target.write_text(team)
129
+ # Offline native import adapter. Injected items lack the bootstrap user event
130
+ # required for desktop discovery. Only this private, stopped seed is edited.
131
+ db=sqlite3.connect(home/'state_5.sqlite')
132
+ try:
133
+ for tid,path,note in records:
134
+ rollout=pathlib.Path(path);lines=[json.loads(line) for line in rollout.read_bytes().splitlines()];ordinal=max(r.get('ordinal',0) for r in lines)+1
135
+ with rollout.open('ab') as stream:stream.write(canonical({'timestamp':now(),'ordinal':ordinal,'type':'event_msg','payload':{'type':'user_message','message':note,'images':[],'local_images':[]}})+b'\n')
136
+ db.execute('UPDATE threads SET has_user_event=1,first_user_message=?,preview=?,memory_mode=? WHERE id=?',(note,note,'disabled',tid))
137
+ db.commit()
138
+ finally:db.close()
139
+ with Backend(binary,home,root) as b:
140
+ listed=set();cursor=None
141
+ while True:
142
+ params={'limit':100}
143
+ if cursor:params['cursor']=cursor
144
+ page=b.call('thread/list',params);listed.update(t['id'] for t in page['data']);cursor=page.get('nextCursor')
145
+ if not cursor:break
146
+ if not {a['threadId'] for a in seeded}<=listed:raise PackError('Native catalog did not discover every fresh agent')
147
+ for a in seeded:
148
+ b.call('thread/read',{'threadId':a['threadId'],'includeTurns':False})
149
+ if b.call('thread/turns/list',{'threadId':a['threadId'],'limit':1,'itemsView':'notLoaded'})['data']:raise PackError('Fresh seed unexpectedly has turns')
150
+ manifest,rev=native.capture(home,lib,name,template={'agents':seeded,'dependencies':m.get('dependencies',{})},workspace_mode='copy',live=False)
151
+ manifest['startingContext']={'kind':'fresh','seedHome':str(home),'sourcePack':source,'sourceRevision':revision,'createdAt':now(),'modelTurnsStarted':False,'previousHistoryCopied':False,'reviewFilesIncluded':False,'memoryMode':'disabled'}
152
+ if authored is not None:
153
+ manifest['startingContext']['templateId']=m['packId']
154
+ manifest['startingContext']['templateVersion']=m['templateVersion']
155
+ manifest['startingContext']['templateDigest']=revision
156
+ rev=lib.save(manifest)
157
+ return {'state':'fresh-pack-created-not-activated','pack':name,'revision':rev,'agents':len(seeded),'nativeCatalogVerified':True,'modelTurnsStarted':False,'previousHistoryCopied':False,'contextDirectory':str(context)}
158
+ finally:shutil.rmtree(root)
@@ -0,0 +1,403 @@
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
+ from .execution import load_gate, qualify_protocol
28
+
29
+ def socket_path(lib):
30
+ directory=pathlib.Path('/tmp')/('topolo-agent-pack-'+hashlib.sha256(str(lib.root).encode()).hexdigest()[:16])
31
+ if directory.exists() and (directory.is_symlink() or directory.stat().st_uid!=os.getuid()): raise PackError('Unowned live bridge directory')
32
+ directory.mkdir(mode=0o700,exist_ok=True)
33
+ return directory/'control.sock'
34
+
35
+ def request(lib,operation):
36
+ record=read_json(lib.root/'live-bridge.json',{})
37
+ if not record: raise PackError('This desktop profile has no live bridge. Open it through topolo-agent-pack first.')
38
+ with socket.socket(socket.AF_UNIX,socket.SOCK_STREAM) as connection:
39
+ connection.settimeout(3 if operation=='status' else 20)
40
+ try: connection.connect(record['socket'])
41
+ except OSError as exc: raise PackError('Managed live bridge is unavailable: '+str(exc)) from exc
42
+ connection.sendall(canonical({'token':record['token'],'operation':operation})+b'\n')
43
+ with connection.makefile('rb') as stream:
44
+ line=stream.readline(1024*1024)
45
+ if not line: raise PackError('Live bridge closed without a reply')
46
+ reply=json.loads(line)
47
+ if 'error' in reply: raise PackError(reply['error'])
48
+ return reply
49
+
50
+ def available(lib):
51
+ try: return request(lib,'status')
52
+ except (PackError,OSError,ValueError): return None
53
+
54
+ def quiesce(lib,timeout=10):
55
+ deadline=time.monotonic()+timeout
56
+ while True:
57
+ state=request(lib,'status')
58
+ if state.get('activeTurns'):raise PackError('Outgoing agents have active turns. Wait for their current turns; Codex can stay open.')
59
+ if not state.get('pendingRequests'):
60
+ try:return request(lib,'quiesce')
61
+ except PackError as exc:
62
+ if 'in flight' not in str(exc):raise
63
+ if time.monotonic()>=deadline:raise PackError('Backend requests have not settled; no switch committed. Retry with Codex still open.')
64
+ time.sleep(0.05)
65
+
66
+ class BackendProxy:
67
+ def __init__(self,lib,real,args):
68
+ self.lib=lib;self.real=real;self.args=args;self.child=None
69
+ self.pending=set();self.active_turns=set();self.initialized=False
70
+ self.quiesced=False;self.output_task=None;self.closed=False
71
+ self.token=secrets.token_hex(32);self.path=socket_path(lib)
72
+ self.execution=None;self.catalog_ready=asyncio.Event();self.catalog_id='topolo-catalog-'+uuid.uuid4().hex
73
+ self.catalog_rows=[];self.catalog_cursors=set()
74
+
75
+ async def start_child(self):
76
+ active=self.lib.active()
77
+ if not active.get('runtime'): raise PackError('No selected managed runtime')
78
+ self.runtime=active['runtime'];env=os.environ.copy()
79
+ self.execution=load_gate(self.runtime);self.catalog_ready.clear()
80
+ self.catalog_rows=[];self.catalog_cursors=set()
81
+ self.protocol=qualify_protocol(self.real) if self.execution else None
82
+ env['CODEX_HOME']=str(pathlib.Path(self.runtime)/'home');env['CODEX_CLI_PATH']=self.real
83
+ 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)
84
+ self.output_task=asyncio.create_task(self.output())
85
+
86
+ async def output(self):
87
+ while line:=await self.child.stdout.readline():
88
+ value=json.loads(line)
89
+ if value.get('id')==self.catalog_id:
90
+ self.pending.discard(self.catalog_id)
91
+ if self.execution and 'result' in value:
92
+ result=value['result'];self.catalog_rows.extend(result['data'])
93
+ cursor=result.get('nextCursor')
94
+ if cursor:
95
+ if cursor in self.catalog_cursors or len(self.catalog_cursors)>=20:
96
+ self.execution.catalog={};self.catalog_ready.set();continue
97
+ self.catalog_cursors.add(cursor)
98
+ await self.request_catalog(cursor);continue
99
+ self.execution.set_catalog({'data':self.catalog_rows})
100
+ self.catalog_ready.set();continue
101
+ if self.execution:
102
+ tracked=str(value.get('id')) in self.execution.pending
103
+ value=self.execution.reply(value);line=canonical(value)+b'\n'
104
+ if tracked:atomic_json(pathlib.Path(self.runtime)/'execution-proof.json',
105
+ {**self.execution.status(),'protocol':self.protocol})
106
+ if 'id' in value and ('result' in value or 'error' in value):
107
+ self.pending.discard(str(value['id']))
108
+ if 'result' in value and isinstance(value['result'],dict) and 'userAgent' in value['result']: self.initialized=True
109
+ method=value.get('method');params=value.get('params') or {}
110
+ if method=='turn/started': self.active_turns.add(params['threadId'])
111
+ if method=='turn/completed': self.active_turns.discard(params['threadId'])
112
+ if method=='thread/status/changed':
113
+ if params.get('status',{}).get('type')=='active':self.active_turns.add(params['threadId'])
114
+ elif params.get('status',{}).get('type') in {'idle','notLoaded','systemError'}:self.active_turns.discard(params['threadId'])
115
+ sys.stdout.buffer.write(line);sys.stdout.buffer.flush()
116
+
117
+ async def stop_child(self):
118
+ if self.child and self.child.returncode is None:
119
+ self.child.terminate()
120
+ try: await asyncio.wait_for(self.child.wait(),5)
121
+ except asyncio.TimeoutError:
122
+ # Never escalate to killing a child whose stop was not confirmed.
123
+ raise PackError('Owned backend did not stop; no storage switch committed')
124
+ if self.output_task: await self.output_task
125
+
126
+ async def forward(self,value):
127
+ if self.quiesced:
128
+ if 'id' in value:
129
+ sys.stdout.buffer.write(canonical({'id':value['id'],'error':{'code':-32000,'message':'Pack switch in progress; reconnecting backend'}})+b'\n');sys.stdout.buffer.flush()
130
+ return
131
+ if self.execution and value.get('method') in {'thread/resume','turn/start'} and (value.get('params') or {}).get('threadId') in self.execution.roles:
132
+ try:
133
+ await asyncio.wait_for(self.catalog_ready.wait(),10)
134
+ value=self.execution.prepare(value)
135
+ except (PackError,asyncio.TimeoutError) as exc:
136
+ sys.stdout.buffer.write(canonical({'id':value.get('id'),'error':{'code':-32000,'message':str(exc) or 'Native model catalog unavailable'}})+b'\n');sys.stdout.buffer.flush();return
137
+ if 'id' in value and 'method' in value:self.pending.add(str(value['id']))
138
+ self.child.stdin.write(canonical(value)+b'\n');await self.child.stdin.drain()
139
+ if self.execution and value.get('method')=='initialized':
140
+ await self.request_catalog()
141
+
142
+ async def request_catalog(self,cursor=None):
143
+ self.pending.add(self.catalog_id)
144
+ self.child.stdin.write(canonical({'id':self.catalog_id,'method':'model/list',
145
+ 'params':{'includeHidden':True,**({'cursor':cursor} if cursor else {})}})+b'\n')
146
+ await self.child.stdin.drain()
147
+
148
+ def status(self):
149
+ return {'pid':os.getpid(),'childPid':self.child.pid if self.child else None,'desktopPid':os.getppid(),'runtime':self.runtime,
150
+ 'quiesced':self.quiesced,'initialized':self.initialized,
151
+ 'activeTurns':sorted(self.active_turns),'pendingRequests':len(self.pending),
152
+ 'executionPolicy':self.execution.status() if self.execution else {'state':'not-configured'},
153
+ 'executionProtocol':self.protocol if self.execution else None}
154
+
155
+ async def control(self,reader,writer):
156
+ try:
157
+ message=json.loads(await asyncio.wait_for(reader.readline(),5))
158
+ if not secrets.compare_digest(message.get('token',''),self.token): raise PackError('Live bridge authentication failed')
159
+ op=message.get('operation')
160
+ if op=='quiesce':
161
+ if self.active_turns: raise PackError('Outgoing agents have active turns. Wait for their current turns; Codex can stay open.')
162
+ if self.pending: raise PackError('Backend requests are in flight. Retry the switch; Codex can stay open.')
163
+ self.quiesced=True
164
+ await self.stop_child()
165
+ elif op=='resume':
166
+ if self.quiesced: raise PackError('Use the desktop reconnect action to resume a quiesced bridge')
167
+ elif op!='status': raise PackError('Unknown live bridge operation')
168
+ result=self.status()
169
+ except Exception as exc: result={'error':str(exc)}
170
+ writer.write(canonical(result)+b'\n');await writer.drain();writer.close();await writer.wait_closed()
171
+
172
+ async def run(self):
173
+ task=asyncio.current_task();loop=asyncio.get_running_loop()
174
+ loop.add_signal_handler(signal.SIGTERM,task.cancel)
175
+ loop.add_signal_handler(signal.SIGINT,task.cancel)
176
+ if self.path.exists():
177
+ previous=available(self.lib)
178
+ if previous: raise PackError('A managed desktop already owns this bridge')
179
+ if not self.path.is_socket() or self.path.stat().st_uid!=os.getuid(): raise PackError('Unowned socket path')
180
+ self.path.unlink()
181
+ server=await asyncio.start_unix_server(self.control,path=str(self.path),limit=1024*1024)
182
+ self.path.chmod(0o600)
183
+ atomic_json(self.lib.root/'live-bridge.json',{'pid':os.getpid(),'socket':str(self.path),'token':self.token})
184
+ try:
185
+ await self.start_child()
186
+ reader=asyncio.StreamReader(limit=64*1024*1024)
187
+ protocol=asyncio.StreamReaderProtocol(reader)
188
+ await asyncio.get_running_loop().connect_read_pipe(lambda:protocol,sys.stdin.buffer)
189
+ while line:=await reader.readline():
190
+ value=json.loads(line)
191
+ await self.forward(value)
192
+ finally:
193
+ await self.stop_child();server.close();await server.wait_closed()
194
+ record=read_json(self.lib.root/'live-bridge.json',{})
195
+ if record.get('pid')==os.getpid():
196
+ (self.lib.root/'live-bridge.json').unlink(missing_ok=True);self.path.unlink(missing_ok=True)
197
+
198
+ def proxy_main():
199
+ lib=Library(os.environ['CODEX_PACK_HOME']);real=os.environ['CODEX_PACK_REAL_CLI']
200
+ args=sys.argv[1:]
201
+ if 'app-server' not in args: os.execv(real,[real,*args])
202
+ try: asyncio.run(BackendProxy(lib,real,args).run())
203
+ except asyncio.CancelledError: return 0
204
+ except Exception as exc:
205
+ print('topolo-agent-pack backend: '+str(exc),file=sys.stderr);return 2
206
+ return 0
207
+
208
+ class WebSocket:
209
+ """Small masked RFC6455 client for the owned Electron debug endpoint."""
210
+ def __init__(self,url):
211
+ uri=urllib.parse.urlsplit(url)
212
+ if uri.scheme!='ws' or uri.hostname not in {'127.0.0.1','localhost'}: raise PackError('Debug endpoint must be local')
213
+ self.socket=socket.create_connection((uri.hostname,uri.port),timeout=20);self.buffer=b''
214
+ key=base64.b64encode(os.urandom(16)).decode()
215
+ 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'
216
+ self.socket.sendall(headers.encode())
217
+ while b'\r\n\r\n' not in self.buffer:
218
+ chunk=self.socket.recv(65536)
219
+ if not chunk:self.socket.close();raise PackError('Desktop debug handshake closed')
220
+ self.buffer+=chunk
221
+ if len(self.buffer)>65536:self.socket.close();raise PackError('Oversized debug handshake')
222
+ header,self.buffer=self.buffer.split(b'\r\n\r\n',1)
223
+ accept=base64.b64encode(hashlib.sha1((key+'258EAFA5-E914-47DA-95CA-C5AB0DC85B11').encode()).digest())
224
+ if not header.startswith(b'HTTP/1.1 101') or accept not in header: self.socket.close();raise PackError('Debug WebSocket handshake rejected')
225
+ self.seq=0
226
+ def read(self,n):
227
+ while len(self.buffer)<n:
228
+ chunk=self.socket.recv(max(65536,n-len(self.buffer)))
229
+ if not chunk: raise PackError('Desktop debug connection closed')
230
+ self.buffer+=chunk
231
+ result,self.buffer=self.buffer[:n],self.buffer[n:];return result
232
+ def send(self,payload,opcode=1):
233
+ mask=os.urandom(4);size=len(payload)
234
+ 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)
235
+ self.socket.sendall(prefix+mask+bytes(v^mask[i%4] for i,v in enumerate(payload)))
236
+ def receive(self):
237
+ pieces=[]
238
+ while True:
239
+ a,b=self.read(2);size=b&127
240
+ if size==126:size=struct.unpack('!H',self.read(2))[0]
241
+ elif size==127:size=struct.unpack('!Q',self.read(8))[0]
242
+ if size>64*1024*1024: raise PackError('Oversized debug reply')
243
+ mask=self.read(4) if b&128 else None;payload=self.read(size)
244
+ if mask:payload=bytes(v^mask[i%4] for i,v in enumerate(payload))
245
+ op=a&15
246
+ if op==8: raise PackError('Desktop debug connection closed')
247
+ if op==9:self.send(payload,10);continue
248
+ if op==10:continue
249
+ pieces.append(payload)
250
+ if a&128:return json.loads(b''.join(pieces))
251
+ def evaluate(self,expression):
252
+ self.seq+=1;self.send(canonical({'id':self.seq,'method':'Runtime.evaluate','params':{'expression':expression,'awaitPromise':True,'returnByValue':True}}))
253
+ while True:
254
+ reply=self.receive()
255
+ if reply.get('id')!=self.seq:continue
256
+ 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')))
257
+ return reply['result']['result'].get('value')
258
+ def close(self):self.socket.close()
259
+
260
+ def renderer(lib):
261
+ file=lib.root/'desktop-data'/'DevToolsActivePort'
262
+ if not file.exists(): raise PackError('Managed window has no reconnect bridge; launch it through the updated topolo-agent-pack command')
263
+ port=int(file.read_text().splitlines()[0])
264
+ opener=urllib.request.build_opener(urllib.request.ProxyHandler({}))
265
+ with opener.open('http://127.0.0.1:'+str(port)+'/json/list',timeout=5) as response:targets=json.load(response)
266
+ for target in targets:
267
+ if target['type']=='page' and target.get('url','').startswith(('app://','file://')):
268
+ ws=WebSocket(target['webSocketDebuggerUrl'])
269
+ if ws.evaluate("typeof electronBridge !== 'undefined' && !!document.querySelector('[aria-label=\"App navigation\"]') && !!document.querySelector('[aria-label^=\"Switch mode\"]')"):
270
+ return ws,target['id']
271
+ ws.close()
272
+ raise PackError('Managed primary Codex window not found')
273
+
274
+ def fetch(ws,method,body):
275
+ request_id='topolo-agent-pack-'+uuid.uuid4().hex
276
+ message={'type':'fetch','requestId':request_id,'url':'vscode://codex/'+method,'method':'POST','body':json.dumps(body),'headers':{}}
277
+ 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)
278
+ return ws.evaluate(expression)
279
+
280
+ def adapter(app='/Applications/ChatGPT.app'):
281
+ # Resolve the app's exported host capability from its installed assets.
282
+ # Refuse changed layouts instead of guessing a private versioned symbol.
283
+ with (pathlib.Path(app)/'Contents/Resources/app.asar').open('rb') as stream:
284
+ header=struct.unpack('<4I',stream.read(16));tree=json.loads(stream.read(header[3]));base=8+header[1]
285
+ assets=tree['files']['webview']['files']['assets']['files']
286
+ for name,entry in assets.items():
287
+ if not name.startswith('app-initial-') or not name.endswith('.js'):continue
288
+ stream.seek(base+int(entry['offset']));source=stream.read(entry['size']).decode()
289
+ match=re.search(r'let ([\w$]+)=([\w$]+)\?\.localThreadCatalog',source)
290
+ if not match:continue
291
+ symbol=match[2]
292
+ for clause in re.finditer(r'import\{([^;]+?)\}from"([^\"]+)";',source[:150000]):
293
+ export=re.search(r'(?:^|,)([\w$]+) as '+re.escape(symbol)+r'(?:,|$)',clause[1])
294
+ if export:return {'module':'/assets/'+clause[2].removeprefix('./'),'export':export[1]}
295
+ raise PackError('This desktop build has no recognized catalog bridge; no switch started')
296
+
297
+ def thread_ids(runtime):
298
+ connection=sqlite3.connect('file:'+str(pathlib.Path(runtime)/'home/state_5.sqlite')+'?mode=ro',uri=True)
299
+ try:return [r[0] for r in connection.execute('SELECT id FROM threads WHERE archived=0')]
300
+ finally:connection.close()
301
+
302
+ def rendered_threads(ws):
303
+ 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")}))')
304
+
305
+ def checkpoint_ui(lib,runtime):
306
+ ws,_=renderer(lib)
307
+ try:
308
+ path=pathlib.Path(runtime)/'home/.codex-global-state.json';gs=read_json(path)
309
+ for key in list(gs):
310
+ value=fetch(ws,'get-global-state',{'key':key}).get('value')
311
+ if key=='electron-persisted-atom-state':
312
+ value={k:(value or {}).get(k,v) for k,v in gs[key].items()}
313
+ if value is not None:gs[key]=value
314
+ atomic_json(path,gs)
315
+ 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')
316
+ atomic_json(pathlib.Path(runtime)/'desktop-view.json',{'threadId':selected})
317
+ finally:ws.close()
318
+
319
+ def reconcile(ws,capability,incoming,outgoing):
320
+ params=json.dumps({'adapter':capability,'incoming':incoming,'outgoing':outgoing})
321
+ 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))
322
+
323
+ def reconnect(lib,runtime,previous_runtime=None,app='/Applications/ChatGPT.app'):
324
+ ws,target_id=renderer(lib)
325
+ try:
326
+ current_bridge=available(lib)
327
+ if current_bridge and not current_bridge['quiesced']:quiesce(lib)
328
+ capability=adapter(app);bind(lib,runtime)
329
+ gs=read_json(pathlib.Path(runtime)/'home'/'.codex-global-state.json')
330
+ current=fetch(ws,'get-global-state',{'key':'electron-persisted-atom-state'}).get('value') or {}
331
+ updates={**gs,'electron-persisted-atom-state':{**current,**gs.get('electron-persisted-atom-state',{})}}
332
+ for key,value in updates.items():
333
+ result=fetch(ws,'set-global-state',{'key':key,'value':value})
334
+ if not result.get('success'): raise PackError('Desktop refused state update: '+key)
335
+ ws.evaluate('electronBridge.sendMessageFromView({type:"codex-app-server-restart",hostId:"local",intent:"reconnect",killCodexProcess:false}).then(()=>true)')
336
+ incoming=thread_ids(runtime);outgoing=sorted(set(thread_ids(previous_runtime) if previous_runtime else [])-set(incoming))
337
+ deadline=time.monotonic()+30
338
+ while time.monotonic()<deadline:
339
+ bridge=available(lib)
340
+ if bridge and not bridge['quiesced'] and bridge['runtime']==str(runtime) and bridge['initialized']:break
341
+ time.sleep(0.05)
342
+ else:raise PackError('Managed backend did not reconnect; run topolo-agent-pack recover')
343
+ proof=reconcile(ws,capability,incoming,outgoing)
344
+ 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')
345
+ # Soft reload clears thread/composer caches while retaining this exact
346
+ # renderer target and native window. No window or desktop process quits.
347
+ view=read_json(pathlib.Path(runtime)/'desktop-view.json',{})
348
+ selected=view.get('threadId') if view.get('threadId') in incoming else None
349
+ navigation={'path':'/local/'+selected if selected else '/','replace':True,'persistForReload':True}
350
+ 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})()')
351
+ ws.evaluate('setTimeout(()=>location.reload(),0);true')
352
+ ws.close()
353
+ sidebar=[]
354
+ while time.monotonic()<deadline:
355
+ try:
356
+ ws,confirmed=renderer(lib)
357
+ if confirmed!=target_id:raise PackError('Desktop switched renderer targets instead of retaining the window')
358
+ rows=rendered_threads(ws);sidebar=[r['title'] for r in rows]
359
+ text=ws.evaluate('document.body.innerText')
360
+ shown={r['id'] for r in rows}
361
+ if text and shown and shown<=set(incoming) and not shown & set(outgoing):break
362
+ ws.close();time.sleep(0.05)
363
+ except (OSError,PackError):time.sleep(0.05)
364
+ else:raise PackError('Desktop did not render after reconnect; run recover')
365
+ 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}
366
+ finally:ws.close()
367
+
368
+ def prepare(lib,real):
369
+ home=lib.root/'desktop-home'
370
+ if home.exists() and not (lib.root/'desktop-layout.json').exists():raise PackError('Existing desktop-home is unowned; choose a separate pack library')
371
+ folder=lib.root/'launchers';folder.mkdir(exist_ok=True,mode=0o700)
372
+ executable=folder/'topolo-agent-pack-backend'
373
+ source=pathlib.Path(__file__).resolve().parents[1]
374
+ 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)
375
+ home.mkdir(exist_ok=True,mode=0o700)
376
+ atomic_json(lib.root/'desktop-layout.json',{'owner':'topolo-agent-pack','schemaVersion':1})
377
+ bind(lib,lib.active()['runtime'])
378
+ 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'))
379
+ return executable,home
380
+
381
+ def bind(lib,runtime):
382
+ if not (lib.root/'desktop-layout.json').exists():return
383
+ if read_json(lib.root/'desktop-layout.json').get('owner') not in {'topolo-agent-pack','codex-pack'}:raise PackError('Unowned desktop layout')
384
+ target=lib.root/'selected-runtime';temporary=lib.root/('.selected-'+uuid.uuid4().hex)
385
+ if target.exists() and not target.is_symlink():raise PackError('Selected-runtime path is unowned')
386
+ temporary.symlink_to(pathlib.Path(runtime));os.replace(temporary,target)
387
+ home=lib.root/'desktop-home'
388
+ paths=['state_5.sqlite','thread_history_1.sqlite','goals_1.sqlite','auth.json','config.toml','AGENTS.md','sessions','archived_sessions','skills','agents','worktrees']
389
+ for name in ['sessions','archived_sessions','skills','agents','worktrees']:
390
+ (pathlib.Path(runtime)/'home'/name).mkdir(exist_ok=True,mode=0o700)
391
+ for name in paths:
392
+ dest=home/name
393
+ expected=pathlib.Path('..')/'selected-runtime'/'home'/name
394
+ if dest.is_symlink():
395
+ if os.readlink(dest)!=str(expected):raise PackError('Unowned desktop route: '+name)
396
+ continue
397
+ if dest.exists():
398
+ # Earlier manager prototypes used ordinary control-home files.
399
+ # Keep them for inspection when migrating to the routed home.
400
+ backup=home/('legacy-'+name)
401
+ if backup.exists():raise PackError('Cannot preserve existing desktop-home file: '+name)
402
+ dest.rename(backup)
403
+ dest.symlink_to(expected)