@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,443 @@
1
+ """The topolo-agent-pack command interface and recoverable composition transactions."""
2
+ import argparse
3
+ import copy
4
+ import hashlib
5
+ import json
6
+ import os
7
+ import pathlib
8
+ import selectors
9
+ import shutil
10
+ import sqlite3
11
+ import subprocess
12
+ import sys
13
+ import tempfile
14
+ import time
15
+ import uuid
16
+
17
+ from . import __version__
18
+ from . import native, live, fresh, blueprint, execution
19
+ from .storage import Library, PackError, atomic_json, canonical, component_key, now, read_json, references, safe_id, select, sync_tree
20
+
21
+ def emit(value):
22
+ print(json.dumps(value, indent=2, ensure_ascii=False))
23
+
24
+ def source_components(manifest, revision, selected):
25
+ return [{'key':component_key(a),'sourcePack':manifest['packId'],'revision':revision,'originPack':manifest['packId'],'originRevision':revision,'threadId':a['threadId']} for a in selected]
26
+
27
+ def make_plan(lib, args):
28
+ active = lib.active(); existing = active.get('components',[])
29
+ saved=read_json(lib.root/'workbenches'/(safe_id(args.source)+'.json'),{})
30
+ resume=bool(args.action=='switch' and saved and not getattr(args,'fresh',False))
31
+ if resume:
32
+ candidates=[]
33
+ for c in saved['components']:
34
+ m,_=lib.load(c['sourcePack'],c['revision'])
35
+ candidates.extend(a for a in m['agents'] if component_key(a)==c['key'])
36
+ manifest={'agents':candidates,'warnings':[]}
37
+ chosen=select(manifest,args.sets,args.agents)
38
+ incoming=[c for c in saved['components'] if c['key'] in {component_key(a) for a in chosen}]
39
+ revision='saved-workbench'
40
+ else:
41
+ manifest, revision = lib.load(args.source)
42
+ chosen = select(manifest, args.sets, args.agents)
43
+ incoming = source_components(manifest,revision,chosen)
44
+ if args.action=='switch' and active.get('name')==args.source and not args.sets and not args.agents and not getattr(args,'fresh',False):
45
+ return {'noChange':True,'before':active}
46
+ before = {c['key']:c for c in existing}
47
+ selected_keys = {c['key'] for c in incoming}
48
+ remove = set()
49
+ if args.action == 'switch': remove = set(before)
50
+ elif args.action == 'replace':
51
+ target = getattr(args,'target',None)
52
+ if target:
53
+ parts = target.split('/')
54
+ if not 1 <= len(parts) <= 3: raise PackError('--target must be PACK[/SET[/AGENT]]')
55
+ for part in parts: safe_id(part)
56
+ remove = {k for k in before if k == target or k.startswith(target+'/')}
57
+ if not remove: raise PackError('Replacement target is not in the active composition')
58
+ if len(parts)==3 and len(incoming)!=1: raise PackError('An individual target accepts exactly one incoming agent')
59
+ if len(parts)==2 and len({a['set'] for a in chosen})!=1: raise PackError('A set target accepts exactly one incoming set')
60
+ elif args.agents:
61
+ remove=selected_keys
62
+ if not remove <= set(before): raise PackError('Replacement agent is not active; use add or supply --target.')
63
+ elif args.sets:
64
+ prefixes={a['pack']+'/'+a['set']+'/' for a in chosen}
65
+ remove={k for k in before if any(k.startswith(p) for p in prefixes)}
66
+ if any(not any(k.startswith(p) for k in before) for p in prefixes): raise PackError('Replacement set is not active; use add or supply --target.')
67
+ else: remove=set(before)
68
+ elif args.action != 'add': raise PackError('Unknown composition action')
69
+ remaining={k:v for k,v in before.items() if k not in remove}
70
+ added=[]
71
+ for c in incoming:
72
+ if c['key'] in remaining:
73
+ old=remaining[c['key']]
74
+ if old.get('originRevision',old['revision'])==c.get('originRevision',c['revision']) and old.get('originPack',old['sourcePack'])==c.get('originPack',c['sourcePack']): continue
75
+ raise PackError('add would overwrite an installed identity: '+c['key']+'. Use replace.')
76
+ remaining[c['key']]=c; added.append(c['key'])
77
+ components=list(remaining.values())
78
+ threads=[c['threadId'] for c in components]
79
+ if len(threads)!=len(set(threads)): raise PackError('Native thread identity collision across selected agents')
80
+ return {'operation':args.action,'source':args.source,'sourceRevision':revision,'before':active,
81
+ 'components':components,'removed':sorted(remove),'added':added,'noChange':not remove and not added,
82
+ 'name':args.source if args.action=='switch' or not existing else active.get('name',args.source),
83
+ 'resumeRuntime':saved.get('runtime') if resume and not args.sets and not args.agents else None,
84
+ 'reuseOutgoingRuntime':args.action=='switch' and not args.sets and not args.agents,
85
+ 'runtimeCheckpointRequired':bool(existing),'warnings':manifest.get('warnings',[]),
86
+ 'activation':'not tested by plan; --no-open commits storage only'}
87
+
88
+ def maps(values):
89
+ result={}
90
+ for value in values or []:
91
+ if '=' not in value: raise PackError('--map requires OLD=NEW')
92
+ old,new=value.split('=',1)
93
+ old=str(pathlib.Path(old).expanduser().resolve()); new=str(pathlib.Path(new).expanduser().resolve())
94
+ if not pathlib.Path(new).exists(): raise PackError('Mapped destination does not exist: '+new)
95
+ if old in result and result[old]!=new: raise PackError('Conflicting path mapping')
96
+ result[old]=new
97
+ return result
98
+
99
+ def verify_backend(lib, runtime, cli):
100
+ """Read back through the installed native protocol; do not load/resume or start turns."""
101
+ if not cli: raise PackError('Native Codex binary not found; supply --codex-bin.')
102
+ cli=pathlib.Path(cli).expanduser().resolve()
103
+ home=pathlib.Path(runtime)/'home'
104
+ connection=native.ro(home/'state_5.sqlite')
105
+ try: expected_cwds=dict(connection.execute('SELECT id,cwd FROM threads'))
106
+ finally: connection.close()
107
+ stderr=open(pathlib.Path(runtime)/'backend-verify.log','a')
108
+ env=os.environ.copy(); env['CODEX_HOME']=str(home)
109
+ for key in list(env):
110
+ if key.startswith('CODEX_APP_TOOLS_'): del env[key]
111
+ proc=subprocess.Popen([str(cli),'app-server','--listen','stdio://'],cwd=runtime,env=env,stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=stderr,bufsize=0)
112
+ sel=selectors.DefaultSelector(); sel.register(proc.stdout,selectors.EVENT_READ)
113
+ seq=0; buffer=b''
114
+ def request(method,params):
115
+ nonlocal seq,buffer
116
+ seq+=1; proc.stdin.write(canonical({'id':seq,'method':method,'params':params})+b'\n'); proc.stdin.flush()
117
+ deadline=time.monotonic()+20
118
+ while time.monotonic()<deadline:
119
+ while b'\n' in buffer:
120
+ line,buffer=buffer.split(b'\n',1)
121
+ if not line.strip(): continue
122
+ value=json.loads(line)
123
+ if value.get('id')==seq:
124
+ if 'error' in value: raise PackError('Native protocol '+method+': '+value['error']['message'])
125
+ return value['result']
126
+ if not sel.select(max(0,deadline-time.monotonic())): break
127
+ chunk=os.read(proc.stdout.fileno(),65536)
128
+ if not chunk: raise PackError('Native verification process exited')
129
+ buffer+=chunk
130
+ raise PackError('Native read-back timed out: '+method)
131
+ checked=[]
132
+ try:
133
+ init=request('initialize',{'clientInfo':{'name':'topolo_agent_pack','version':__version__},'capabilities':{'experimentalApi':True}})
134
+ proc.stdin.write(canonical({'method':'initialized'})+b'\n');proc.stdin.flush()
135
+ agents=read_json(pathlib.Path(runtime)/'agents.json')['agents']
136
+ for a in agents:
137
+ tid=a['threadId']; result=request('thread/read',{'threadId':tid,'includeTurns':False})
138
+ if result['thread']['id']!=tid: raise PackError('Native thread identity read-back mismatch')
139
+ expected=str(pathlib.Path(expected_cwds[tid]).resolve())
140
+ if str(pathlib.Path(result['thread']['cwd']).resolve())!=expected: raise PackError('Native working-directory read-back mismatch for '+tid+': expected '+expected+', got '+result['thread']['cwd'])
141
+ turns=request('thread/turns/list',{'threadId':tid,'limit':1,'itemsView':'notLoaded'})
142
+ items=request('thread/items/list',{'threadId':tid,'limit':1})
143
+ checked.append({'threadId':tid,'cwdVerified':True,'historyReadable':True,'sampledTurns':len(turns['data']),'sampledItems':len(items['data'])})
144
+ finally:
145
+ proc.terminate()
146
+ try: proc.wait(timeout=5)
147
+ except subprocess.TimeoutExpired: proc.kill(); proc.wait()
148
+ sel.close(); proc.stdin.close(); proc.stdout.close(); stderr.close()
149
+ report={'boundary':'native backend read-back; desktop rendering not verified','agents':checked,'modelTurnsStarted':False}
150
+ atomic_json(pathlib.Path(runtime)/'backend-proof.json',report)
151
+ return report
152
+
153
+ def desktop_open(runtime, app, user_data, codex_bin=None):
154
+ if sys.platform!='darwin': raise PackError('Desktop activation adapter currently supports macOS. Use --no-open for portable storage on this platform.')
155
+ app=pathlib.Path(app).expanduser().resolve()
156
+ if not app.is_dir(): raise PackError('Desktop application not found: '+str(app))
157
+ runtime=pathlib.Path(runtime); user_data=pathlib.Path(user_data); user_data.mkdir(exist_ok=True,mode=0o700)
158
+ lib=Library(user_data.parent);real=codex_bin or find_codex()
159
+ executable,home=live.prepare(lib,real)
160
+ subprocess.run(['/usr/bin/open','-n','--env','CODEX_HOME='+str(home),
161
+ '--env','CODEX_CLI_PATH='+str(executable),'--env','CODEX_PACK_HOME='+str(lib.root),
162
+ '--env','CODEX_PACK_REAL_CLI='+str(real),
163
+ '--env','CODEX_ELECTRON_USER_DATA_PATH='+str(user_data),str(app),
164
+ '--args','--user-data-dir='+str(user_data),'--remote-debugging-port=0','--remote-debugging-address=127.0.0.1','codex://launch'],check=True,timeout=20)
165
+ return {'state':'managed-window-launch-requested','app':str(app),'frontendVerified':False,'note':'One-time managed window launch. Future changes reconnect this same window. Authenticate locally if prompted.'}
166
+
167
+ def apply_plan(lib,plan,args):
168
+ if plan['noChange']:
169
+ active=lib.active()
170
+ if not args.no_open and not live.available(lib):
171
+ native.assert_idle(pathlib.Path(active['runtime'])/'home')
172
+ active['desktop']=desktop_open(active['runtime'],args.app,lib.root/'desktop-data',args.codex_bin)
173
+ active['state']=active['desktop']['state'];atomic_json(lib.root/'active.json',active)
174
+ return {'state':active['state'],'active':active}
175
+ return {'state':'unchanged','active':active}
176
+ if lib.pending(): raise PackError('An incomplete operation needs recovery. Run topolo-agent-pack recover before another mutation.')
177
+ before=plan['before']; old_runtime=pathlib.Path(before['runtime']) if before.get('runtime') else None
178
+ bridge=live.available(lib)
179
+ if bridge:
180
+ if not old_runtime or bridge['runtime']!=str(old_runtime): raise PackError('Live bridge and active runtime disagree; run recover before switching')
181
+ live.adapter(args.app)
182
+ ws,target=live.renderer(lib);ws.close()
183
+ elif old_runtime: native.assert_idle(old_runtime/'home')
184
+ tx=uuid.uuid4().hex; runtime=lib.root/'runtimes'/tx
185
+ journal={'id':tx,'phase':'started','createdAt':now(),'desktopApp':args.app,'before':before,'operation':plan['operation'],'targetRuntime':str(runtime),'plan':{k:v for k,v in plan.items() if k!='before'}}
186
+ jpath=lib.root/'journals'/(tx+'.json'); atomic_json(jpath,journal)
187
+ try:
188
+ if bridge:
189
+ journal['liveBridge']={'targetId':target,'beforePid':bridge['pid'],'desktopPid':bridge['desktopPid']};atomic_json(jpath,journal)
190
+ try:live.quiesce(lib)
191
+ except Exception:
192
+ current=live.available(lib)
193
+ if current and not current['quiesced']:journal['phase']='rolled-back'
194
+ raise
195
+ journal['phase']='quiesced';atomic_json(jpath,journal)
196
+ if old_runtime: native.assert_idle(old_runtime/'home')
197
+ if bridge:live.checkpoint_ui(lib,old_runtime)
198
+ if old_runtime and not plan.get('reuseOutgoingRuntime'):
199
+ known={a['threadId'] for a in read_json(old_runtime/'agents.json')['agents']}
200
+ if native.unregistered_threads(old_runtime/'home',known):
201
+ raise PackError('Additional independent threads need registration before partial composition. Run capture NAME --all, then switch NAME --fresh. Whole-pack switches retain these threads in their runtime.')
202
+ checkpoint_components=copy.deepcopy(before.get('components',[]))
203
+ if old_runtime:
204
+ if plan.get('reuseOutgoingRuntime'):
205
+ # The complete stopped runtime already lives in the independent
206
+ # pack library. Whole-pack switching can retain it in place.
207
+ journal['checkpoint']={'mode':'retained-runtime','runtime':str(old_runtime),'components':checkpoint_components}
208
+ else:
209
+ checkpoint_name='checkpoint-'+tx
210
+ snapshot,revision=native.capture(old_runtime/'home',lib,checkpoint_name,all_threads=True,template=read_json(old_runtime/'agents.json'))
211
+ checkpoint_components=[{**c,'sourcePack':checkpoint_name,'revision':revision} for c in checkpoint_components]
212
+ journal['checkpoint']={'mode':'immutable-snapshot','pack':checkpoint_name,'revision':revision,'components':checkpoint_components}
213
+ journal['phase']='checkpointed';atomic_json(jpath,journal)
214
+ if before.get('name'):
215
+ atomic_json(lib.root/'workbenches'/(safe_id(before['name'])+'.json'),{**before,'components':checkpoint_components})
216
+ checkpoint_by_key={c['key']:c for c in checkpoint_components}
217
+ # Unchanged agents are taken from the current installed state, never the old source template.
218
+ added=set(plan['added'])
219
+ target_components=[checkpoint_by_key.get(c['key'],c) if c['key'] not in added else c for c in plan['components']]
220
+ if plan.get('resumeRuntime'):
221
+ runtime=pathlib.Path(plan['resumeRuntime']);native.assert_idle(runtime/'home')
222
+ if args.mappings: raise PackError('Saved workbench paths are already bound. Use --fresh with --map to rebuild.')
223
+ proof=read_json(runtime/'verification.json')
224
+ journal['targetRuntime']=str(runtime)
225
+ else:
226
+ proof=native.materialize(lib,target_components,runtime,maps(args.mappings))
227
+ atomic_json(runtime/'composition.json',target_components)
228
+ # Authentication and local tool configuration are machine-local and
229
+ # deliberately kept out of portable manifests and export bundles.
230
+ if old_runtime and (old_runtime/'home'/'auth.json').exists():
231
+ shutil.copyfile(old_runtime/'home'/'auth.json',runtime/'home'/'auth.json')
232
+ (runtime/'home'/'auth.json').chmod(0o600)
233
+ local_config=lib.root/'local-config.toml'
234
+ if local_config.exists():
235
+ shutil.copyfile(local_config,runtime/'home'/'config.toml');(runtime/'home'/'config.toml').chmod(0o600)
236
+ if old_runtime and old_runtime!=runtime and (old_runtime/'desktop-view.json').exists():shutil.copyfile(old_runtime/'desktop-view.json',runtime/'desktop-view.json')
237
+ execution.compile_runtime(lib, target_components, runtime)
238
+ journal['phase']='staged';atomic_json(jpath,journal)
239
+ if old_runtime and old_runtime!=runtime and (old_runtime/'home/auth.json').exists():
240
+ shutil.copyfile(old_runtime/'home/auth.json',runtime/'home/auth.json');(runtime/'home/auth.json').chmod(0o600)
241
+ backend=verify_backend(lib,runtime,args.codex_bin)
242
+ if not plan.get('resumeRuntime'): sync_tree(runtime)
243
+ if old_runtime: native.assert_idle(old_runtime/'home')
244
+ if lib.active()!=before: raise PackError('Active composition changed concurrently; refusing to overwrite it.')
245
+ after={'name':plan['name'],'runtime':str(runtime),'components':target_components,'selectedAt':now(),'state':'backend-verified','desktop':{'state':'not-opened','frontendVerified':False},'transaction':tx}
246
+ journal['after']=after;journal['phase']='verified';atomic_json(jpath,journal)
247
+ atomic_json(lib.root/'active.json',after)
248
+ journal['phase']='committed';atomic_json(jpath,journal)
249
+ if bridge or not args.no_open:
250
+ after['desktop']=live.reconnect(lib,runtime,old_runtime,args.app) if bridge else desktop_open(runtime,args.app,lib.root/'desktop-data',args.codex_bin)
251
+ after['state']=after['desktop']['state'];atomic_json(lib.root/'active.json',after)
252
+ journal['after']=after;journal['phase']='complete';atomic_json(jpath,journal)
253
+ return {'state':after['state'],'agents':proof['agents'],'totalHistoryThreads':proof['threads'],'runtime':str(runtime),'checkpoint':journal.get('checkpoint'),
254
+ 'desktop':after['desktop'],'backend':backend,'dependencies':proof['dependencies'],'activeGoalsPaused':proof['activeGoalsPaused']}
255
+ except Exception as exc:
256
+ journal['failure']=str(exc);atomic_json(jpath,journal)
257
+ if bridge and live.available(lib) and live.available(lib).get('quiesced'):
258
+ try:
259
+ current=lib.active();journal['reconnectAfterFailure']=live.reconnect(lib,current['runtime'],old_runtime,args.app);atomic_json(jpath,journal)
260
+ except Exception as reconnect_error:
261
+ journal['reconnectFailure']=str(reconnect_error);atomic_json(jpath,journal)
262
+ raise
263
+
264
+ def recover(lib):
265
+ result=[]
266
+ for path in sorted(lib.pending()):
267
+ j=read_json(path); active=lib.active(); before=j['before']; after=j.get('after')
268
+ previous=(active.get('runtime')==before.get('runtime') and active.get('transaction')==before.get('transaction') and active.get('components')==before.get('components'))
269
+ committed=bool(after and active.get('transaction')==j['id'] and active.get('runtime')==after['runtime'] and active.get('components')==after.get('components'))
270
+ if not previous and not committed:raise PackError('Recovery found a concurrent active-pointer change; no state overwritten. Inspect '+str(path))
271
+ if j.get('liveBridge'):
272
+ j['desktopRecovery']=live.reconnect(lib,active['runtime'],before.get('runtime'),j.get('desktopApp','/Applications/ChatGPT.app'))
273
+ active['desktop']=j['desktopRecovery'];active['state']=active['desktop']['state'];atomic_json(lib.root/'active.json',active)
274
+ if previous:
275
+ j['phase']='rolled-back';j['recovery']='Previous active pointer retained; staged files kept for inspection.'
276
+ elif after and active.get('transaction')==j['id'] and active.get('runtime')==after['runtime']:
277
+ if not j.get('liveBridge'): native.assert_idle(pathlib.Path(active['runtime'])/'home')
278
+ if not (pathlib.Path(active['runtime'])/'backend-proof.json').exists(): raise PackError('Committed runtime lacks backend proof; recovery needs inspection.')
279
+ j['phase']='complete';j['recovery']='Verified committed runtime retained.'+(' Same window reconnected and read back.' if j.get('liveBridge') else ' Desktop activation was not replayed.')
280
+ else: raise PackError('Recovery found a concurrent active-pointer change; no state overwritten. Inspect '+str(path))
281
+ atomic_json(path,j);result.append({'transaction':j['id'],'phase':j['phase'],'action':j['recovery']})
282
+ return {'recovered':result,'active':lib.active()}
283
+
284
+ def find_codex():
285
+ bundled=pathlib.Path('/Applications/ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex')
286
+ return str(bundled) if bundled.exists() else shutil.which('codex')
287
+
288
+ def parser():
289
+ p=argparse.ArgumentParser(description='Capture, install and compose portable Topolo Agent Packs. No cloud service or API key required for storage operations.')
290
+ p.add_argument('--version',action='version',version=__version__)
291
+ p.add_argument('--store',default=os.environ.get('CODEX_PACK_HOME',str(pathlib.Path.home()/'AgentPacks')))
292
+ sub=p.add_subparsers(dest='command',required=True)
293
+ def selection(q):
294
+ q.add_argument('--set',dest='sets',action='append');q.add_argument('--agent',dest='agents',action='append')
295
+ def activation(q):
296
+ q.add_argument('--no-open',action='store_true',help='Commit a backend-verified composition without opening the desktop')
297
+ q.add_argument('--map',dest='mappings',action='append',help='External path binding OLD=NEW (never overwrites the external destination)')
298
+ q.add_argument('--codex-bin',default=find_codex());q.add_argument('--app',default='/Applications/ChatGPT.app')
299
+ cap=sub.add_parser('capture');cap.add_argument('name');selection(cap)
300
+ cap.add_argument('--source-home',help='Source Codex home. Defaults to the selected managed runtime, otherwise CODEX_HOME or ~/.codex')
301
+ cap.add_argument('--include',action='append',help='Explicit additional workflow/asset path')
302
+ cap.add_argument('--all',action='store_true',help='Explicitly capture all non-archived threads when group names do not identify the pack')
303
+ cap.add_argument('--workspace-mode',choices=['copy','reference'],default='copy')
304
+ cap.add_argument('--offline',action='store_true',help='Require an idle source instead of taking a read-only live snapshot')
305
+ q=sub.add_parser('context',help='Extract operating context and conversation rule candidates for review');q.add_argument('source');q.add_argument('directory');selection(q)
306
+ q=sub.add_parser('fresh',help='Create new native agents from reviewed file-backed context, without old work history');q.add_argument('source');q.add_argument('name');q.add_argument('--context',required=True);selection(q);q.add_argument('--codex-bin',default=find_codex())
307
+ q=sub.add_parser('build',help='Build a new native instance from a portable authored template; no source pack required');q.add_argument('name');q.add_argument('--template',required=True);selection(q);q.add_argument('--codex-bin',default=find_codex())
308
+ install=sub.add_parser('install');install.add_argument('bundle');selection(install)
309
+ export=sub.add_parser('export');export.add_argument('name');export.add_argument('destination')
310
+ for action in ['add','replace','switch']:
311
+ q=sub.add_parser(action);q.add_argument('source');selection(q);activation(q)
312
+ q.set_defaults(action=action)
313
+ if action=='add': q.add_argument('--catalog',action='store_true',help='Build an uninstalled bundled catalog pack before adding it')
314
+ if action=='replace': q.add_argument('--target')
315
+ if action=='switch': q.add_argument('--fresh',action='store_true',help='Use the captured source instead of resuming a previously saved workbench')
316
+ q=sub.add_parser('plan');q.add_argument('action',choices=['add','replace','switch']);q.add_argument('source');selection(q);q.add_argument('--target');q.add_argument('--fresh',action='store_true')
317
+ q=sub.add_parser('policy',help='Render portable role settings for review; never changes live sessions')
318
+ q.add_argument('source');selection(q);q.add_argument('--output')
319
+ for command in ['connect','remote-context','remember','artifact','artifact-get','remote-recover']:
320
+ q=sub.add_parser(command,help='Use public Topolo Agent continuity and Bytes artifact contracts')
321
+ q.add_argument('name');q.add_argument('--environment',choices=['development','staging','production'],required=True)
322
+ q.add_argument('--context-key',required=True);q.add_argument('--role',required=command not in ['connect','remote-recover'])
323
+ if command in ['connect','remember','artifact']:q.add_argument('--confirm',action='store_true',required=True)
324
+ if command=='connect':
325
+ q.add_argument('--roles',required=True);q.add_argument('--agent-workspace',required=True);q.add_argument('--bytes-workspace',required=True)
326
+ q.add_argument('--ownership',choices=['user','organization'],required=True);q.add_argument('--bytes-scope',choices=['private','org'],required=True);q.add_argument('--instance')
327
+ if command=='remember':
328
+ q.add_argument('--content',required=True);q.add_argument('--kind',choices=['instruction','knowledge','durable'],required=True)
329
+ q.add_argument('--provenance',required=True);q.add_argument('--visibility',choices=['private','shared'],default='private')
330
+ q.add_argument('--sharing-policy');q.add_argument('--expires-at',type=int)
331
+ if command=='artifact':
332
+ q.add_argument('--file',required=True);q.add_argument('--content-type',default='application/octet-stream')
333
+ q.add_argument('--visibility',choices=['private','shared'],default='private')
334
+ if command=='artifact-get':q.add_argument('--reference',required=True);q.add_argument('--destination',required=True)
335
+ sub.add_parser('status');q=sub.add_parser('list');q.add_argument('name',nargs='?');sub.add_parser('recover')
336
+ q=sub.add_parser('open');q.add_argument('--codex-bin',default=find_codex());q.add_argument('--app',default='/Applications/ChatGPT.app')
337
+ q=sub.add_parser('doctor');q.add_argument('name',nargs='?');q.add_argument('--codex-bin',default=find_codex())
338
+ return p
339
+
340
+ def main(argv=None):
341
+ argv = list(sys.argv[1:] if argv is None else argv)
342
+ harness = 'codex'
343
+ explicit_harness = '--harness' in argv
344
+ if '--harness' in argv:
345
+ at = argv.index('--harness')
346
+ if at + 1 >= len(argv):
347
+ parser().error('--harness requires codex or claude')
348
+ harness = argv[at + 1]
349
+ del argv[at:at + 2]
350
+ policy_command = 'policy' in argv
351
+ if harness == 'claude' and not policy_command:
352
+ from . import claude
353
+ return claude.main(argv)
354
+ if harness not in {'codex', 'claude'}:
355
+ print('topolo-agent-pack: Unsupported harness: ' + harness, file=sys.stderr)
356
+ return 2
357
+ args=parser().parse_args(argv)
358
+ try:
359
+ lib=Library(args.store)
360
+ if args.command in {'connect','remote-context','remember','artifact','artifact-get','remote-recover'}:
361
+ from . import topolo
362
+ with topolo.connection_lock(lib,args):result=topolo.dispatch(lib,args)
363
+ emit(result)
364
+ return 0
365
+ with lib.lock():
366
+ if args.command=='policy':
367
+ from . import policy
368
+ manifest,revision=lib.load(args.source)
369
+ result=policy.render(manifest,revision,select(manifest,args.sets,args.agents),
370
+ (harness,) if explicit_harness else ('codex','claude'))
371
+ if args.output:
372
+ from .claude import write_new
373
+ write_new(pathlib.Path(args.output).expanduser().absolute(), canonical(result)+b'\n')
374
+ elif args.command=='capture':
375
+ safe_id(args.name)
376
+ active=lib.active();template=None
377
+ if args.source_home: home=pathlib.Path(args.source_home)
378
+ elif active.get('runtime'):
379
+ home=pathlib.Path(active['runtime'])/'home';template=read_json(pathlib.Path(active['runtime'])/'agents.json')
380
+ else: home=pathlib.Path(os.environ.get('CODEX_HOME',str(pathlib.Path.home()/'.codex')))
381
+ manifest,revision=native.capture(home,lib,args.name,args.sets,args.agents,args.include,args.all,template,args.workspace_mode,live=not args.offline)
382
+ result={'pack':args.name,'revision':revision,'agents':len(manifest['agents']),'historyThreads':len(manifest['threads']),'warnings':manifest['warnings'],'state':'captured'}
383
+ elif args.command=='context':result=fresh.prepare(lib,args.source,args.directory,args.sets,args.agents)
384
+ elif args.command=='fresh':result=fresh.create(lib,args.source,args.name,args.context,args.codex_bin,args.sets,args.agents)
385
+ elif args.command=='build':result=blueprint.build(lib,args.name,args.template,args.codex_bin,args.sets,args.agents)
386
+ elif args.command=='install':
387
+ manifest,revision=lib.install(args.bundle,publish=False)
388
+ selected=select(manifest,args.sets,args.agents)
389
+ if args.sets or args.agents:
390
+ manifest=native.slice_manifest(lib,manifest,selected)
391
+ revision=lib.save(manifest)
392
+ result={'state':'installed-not-activated','pack':manifest['packId'],'revision':revision,'agents':len(selected),'warnings':manifest['warnings']}
393
+ elif args.command=='export': result=lib.export(args.name,args.destination)
394
+ elif args.command=='list':
395
+ if args.name:
396
+ manifest,revision=lib.load(args.name)
397
+ result={'pack':args.name,'revision':revision,'sets':sorted({a['set'] for a in manifest['agents']}),
398
+ 'agents':[{'key':component_key(a),'title':a['title'],'aliases':a.get('aliases',[])} for a in manifest['agents']]}
399
+ else:
400
+ result={'packs':[{'name':path.parent.name,'revision':read_json(path)['revision']} for path in sorted((lib.root/'packs').glob('*/head.json')) if not path.parent.name.startswith('checkpoint-')],
401
+ 'workbenches':[p.stem for p in sorted((lib.root/'workbenches').glob('*.json'))]}
402
+ elif args.command=='plan': result=make_plan(lib,args)
403
+ elif args.command in {'add','replace','switch'}:
404
+ if args.command=='add' and args.catalog and not (lib.root/'packs'/safe_id(args.source)/'head.json').exists():
405
+ template=pathlib.Path(__file__).resolve().parents[2]/'templates'/safe_id(args.source)
406
+ if not template.is_dir():raise PackError('Unknown catalog pack: '+args.source)
407
+ blueprint.build(lib,args.source,template,args.codex_bin)
408
+ result=apply_plan(lib,make_plan(lib,args),args)
409
+ elif args.command=='recover': result=recover(lib)
410
+ elif args.command=='open':
411
+ active=lib.active()
412
+ if not active.get('runtime'):raise PackError('Select a pack first with switch NAME')
413
+ if live.available(lib):result={'state':'already-open','liveBridge':live.available(lib)}
414
+ else:
415
+ native.assert_idle(pathlib.Path(active['runtime'])/'home')
416
+ result=desktop_open(active['runtime'],args.app,lib.root/'desktop-data',args.codex_bin)
417
+ active['desktop']=result;active['state']=result['state'];atomic_json(lib.root/'active.json',active)
418
+ elif args.command=='status':
419
+ active=lib.active();pids=native.busy_writers(pathlib.Path(active['runtime'])/'home') if active.get('runtime') else []
420
+ result={'store':str(lib.root),'active':active,'liveBridge':live.available(lib),'runtimeDatabaseHolders':pids,'recoveryRequired':[str(p) for p in lib.pending()],'frontendVerified':active.get('desktop',{}).get('frontendVerified',False)}
421
+ elif args.command=='doctor':
422
+ checked=[]
423
+ if args.name:
424
+ manifest,revision=lib.load(args.name)
425
+ for ref in references(manifest).values(): lib.object(ref)
426
+ checked.append({'pack':args.name,'revision':revision,'objectsVerified':len(references(manifest))})
427
+ result={'library':str(lib.root),'nativeCodex':args.codex_bin,'desktopAdapter':'macOS managed backend proxy and same-window reconnect','liveBridge':live.available(lib),
428
+ 'recoveryRequired':bool(lib.pending()),'checked':checked,'version':__version__}
429
+ emit(result)
430
+ return 0
431
+ except (PackError,OSError,ValueError,sqlite_error(),subprocess.SubprocessError,zip_error()) as exc:
432
+ print('topolo-agent-pack: '+str(exc),file=sys.stderr)
433
+ return 2
434
+
435
+ def sqlite_error():
436
+ import sqlite3
437
+ return sqlite3.Error
438
+
439
+ def zip_error():
440
+ import zipfile
441
+ return zipfile.BadZipFile
442
+
443
+ if __name__=='__main__': raise SystemExit(main())
@@ -0,0 +1,154 @@
1
+ """Per-role defaults for the owned Codex proxy, with explicit native read-back.
2
+
3
+ No model turns are created here. A manifest is intent, never activation proof.
4
+ Conflicting frontend settings are rejected instead of silently broadening the
5
+ reviewed role. Changing that intent requires rebuilding the selected policy.
6
+ """
7
+ import copy
8
+ import hashlib
9
+ import json
10
+ import pathlib
11
+ import sqlite3
12
+ import subprocess
13
+ import tempfile
14
+ from contextlib import closing
15
+
16
+ from . import policy
17
+ from .storage import PackError, atomic_json, component_key, read_json
18
+
19
+
20
+ def compile_runtime(lib, components, runtime):
21
+ runtime = pathlib.Path(runtime)
22
+ with closing(sqlite3.connect('file:' + str(runtime / 'home/state_5.sqlite') + '?mode=ro', uri=True)) as db:
23
+ cwds = dict(db.execute('SELECT id,cwd FROM threads'))
24
+ rows = []
25
+ for component in components:
26
+ manifest, revision = lib.load(component['sourcePack'], component['revision'])
27
+ matches = [a for a in manifest['agents'] if component_key(a) == component['key'] and a['threadId'] == component['threadId']]
28
+ if len(matches) != 1 or component['threadId'] not in cwds:
29
+ raise PackError('Execution policy has no unique owned role: ' + component['key'])
30
+ rows.append({'key': component['key'], 'threadId': component['threadId'],
31
+ 'sourceRevision': revision, 'cwd': cwds[component['threadId']],
32
+ 'settings': policy.settings(matches[0], 'codex')['nativeSettings']})
33
+ document = {'format': 'topolo-codex-runtime-execution', 'schemaVersion': 1,
34
+ 'roles': rows, 'applied': False, 'modelTurnsStarted': False}
35
+ # Construct/validate fully before installing intent in a staged runtime.
36
+ ExecutionGate(document)
37
+ atomic_json(runtime / 'execution-policy.json', document)
38
+ return document
39
+
40
+
41
+ class ExecutionGate:
42
+ def __init__(self, document):
43
+ if document.get('format') != 'topolo-codex-runtime-execution' or document.get('schemaVersion') != 1:
44
+ raise PackError('Unqualified runtime execution policy')
45
+ self.roles = {}; self.pending = {}; self.verified = set(); self.catalog = {}
46
+ for role in document['roles']:
47
+ tid = role['threadId']; spec = role['settings']
48
+ if tid in self.roles or not pathlib.Path(role['cwd']).is_absolute():
49
+ raise PackError('Duplicate thread or non-absolute role workspace')
50
+ required = {'model', 'model_reasoning_effort', 'approval_policy', 'approvals_reviewer',
51
+ 'sandbox_mode', 'sandbox_workspace_write', 'apps'}
52
+ if (set(spec) != required or not isinstance(spec['model'], str) or not spec['model'] or
53
+ spec['model_reasoning_effort'] not in {'medium', 'high'} or
54
+ spec['approval_policy'] != 'on-request' or spec['approvals_reviewer'] != 'user' or
55
+ spec['sandbox_mode'] not in {'read-only', 'workspace-write'} or
56
+ spec['sandbox_workspace_write'] != {'network_access': False, 'writable_roots': [],
57
+ 'exclude_slash_tmp': True, 'exclude_tmpdir_env_var': True} or
58
+ spec['apps'] != {'_default': {'default_tools_approval_mode': 'writes'}}):
59
+ raise PackError('Unqualified role execution settings')
60
+ self.roles[tid] = copy.deepcopy(role)
61
+
62
+ def set_catalog(self, result):
63
+ self.catalog = {row['model']: {e['reasoningEffort'] for e in row['supportedReasoningEfforts']}
64
+ for row in result['data']}
65
+
66
+ def sandbox(self, role):
67
+ if role['settings']['sandbox_mode'] == 'read-only':
68
+ return {'type': 'readOnly', 'networkAccess': False}
69
+ return {'type': 'workspaceWrite', 'networkAccess': False, 'writableRoots': [role['cwd']],
70
+ 'excludeSlashTmp': True, 'excludeTmpdirEnvVar': True}
71
+
72
+ def prepare(self, message):
73
+ value = copy.deepcopy(message); method = value.get('method'); params = value.get('params') or {}
74
+ tid = params.get('threadId'); role = self.roles.get(tid)
75
+ if not role or method not in {'thread/resume', 'turn/start'}:
76
+ return value
77
+ if 'id' not in value or str(value['id']) in self.pending:
78
+ raise PackError('Managed execution requires a unique request identity')
79
+ spec = role['settings']
80
+ if spec['model_reasoning_effort'] not in self.catalog.get(spec['model'], set()):
81
+ raise PackError('Role model/effort is absent from the native account catalog; no fallback selected')
82
+ if any(params.get(key) is not None for key in ['permissions', 'path', 'history', 'collaborationMode', 'environments', 'runtimeWorkspaceRoots']):
83
+ raise PackError('Frontend supplied unreviewed permission, identity or execution overrides')
84
+ wanted = {'cwd': role['cwd'], 'model': spec['model'], 'approvalPolicy': spec['approval_policy'],
85
+ 'approvalsReviewer': spec['approvals_reviewer']}
86
+ if method == 'thread/resume':
87
+ wanted['sandbox'] = spec['sandbox_mode']
88
+ wanted['config'] = {'model_reasoning_effort': spec['model_reasoning_effort'],
89
+ 'sandbox_workspace_write': spec['sandbox_workspace_write'], 'apps': spec['apps']}
90
+ self.verified.discard(tid)
91
+ else:
92
+ if tid not in self.verified:
93
+ raise PackError('Role needs a verified native resume before starting a turn')
94
+ wanted.update(effort=spec['model_reasoning_effort'], sandboxPolicy=self.sandbox(role))
95
+ for key, expected in wanted.items():
96
+ if params.get(key) is not None and params[key] != expected:
97
+ raise PackError('Frontend ' + key + ' conflicts with the reviewed role policy')
98
+ params[key] = copy.deepcopy(expected)
99
+ value['params'] = params
100
+ if method == 'thread/resume': self.pending[str(value['id'])] = tid
101
+ return value
102
+
103
+ def reply(self, value):
104
+ tid = self.pending.pop(str(value.get('id')), None)
105
+ if tid is None: return value
106
+ self.verified.discard(tid)
107
+ if 'error' in value: return value
108
+ role = self.roles[tid]; spec = role['settings']; result = value.get('result', {})
109
+ expected = {'model': spec['model'], 'cwd': role['cwd'], 'approvalPolicy': spec['approval_policy'],
110
+ 'approvalsReviewer': spec['approvals_reviewer'], 'reasoningEffort': spec['model_reasoning_effort'],
111
+ 'sandbox': self.sandbox(role)}
112
+ sandbox = result.get('sandbox', {})
113
+ # Native responses can omit false read-only networkAccess defaults.
114
+ actual = copy.deepcopy(result)
115
+ if sandbox.get('type') == 'readOnly': actual['sandbox'] = {'type': 'readOnly', 'networkAccess': sandbox.get('networkAccess', False)}
116
+ if result.get('thread', {}).get('id') != tid or any(actual.get(k) != v for k, v in expected.items()):
117
+ return {'id': value['id'], 'error': {'code': -32000,
118
+ 'message': 'Native role settings differ from the reviewed policy; model work is blocked'}}
119
+ self.verified.add(tid)
120
+ return value
121
+
122
+ def status(self):
123
+ return {'configuredRoles': len(self.roles), 'nativeResumeVerifiedRoles': sorted(self.verified),
124
+ 'requested': [{'key': r['key'], 'threadId': tid, 'settings': r['settings']} for tid, r in self.roles.items()],
125
+ 'appToolApprovalReadbackVerified': False, 'modelIdentityResponseVerified': False}
126
+
127
+
128
+ def load_gate(runtime):
129
+ path = pathlib.Path(runtime) / 'execution-policy.json'
130
+ return ExecutionGate(read_json(path)) if path.exists() else None
131
+
132
+
133
+ def qualify_protocol(binary, run=subprocess.run):
134
+ """Generate schema offline from this executable; never initialize an app server."""
135
+ required = {'ThreadResumeParams': {'threadId', 'model', 'approvalPolicy', 'approvalsReviewer', 'sandbox', 'config'},
136
+ 'ThreadResumeResponse': {'thread', 'model', 'cwd', 'reasoningEffort', 'approvalPolicy', 'approvalsReviewer', 'sandbox'},
137
+ 'TurnStartParams': {'threadId', 'model', 'effort', 'approvalPolicy', 'approvalsReviewer', 'sandboxPolicy'},
138
+ 'ModelListParams': {'includeHidden', 'cursor'}, 'ModelListResponse': {'data', 'nextCursor'}}
139
+ with tempfile.TemporaryDirectory(prefix='topolo-codex-protocol-') as temp:
140
+ result = run([binary, 'app-server', 'generate-json-schema', '--experimental', '--out', temp],
141
+ capture_output=True, timeout=20, check=False)
142
+ if result.returncode:
143
+ raise PackError('Installed Codex protocol export refused; no backend started')
144
+ hashes = {}
145
+ for name, fields in required.items():
146
+ matches = list(pathlib.Path(temp).rglob(name + '.json'))
147
+ if len(matches) != 1:
148
+ raise PackError('Installed Codex protocol has no unique ' + name)
149
+ data = matches[0].read_bytes(); schema = json.loads(data)
150
+ if not fields <= set(schema.get('properties', {})):
151
+ raise PackError('Installed Codex protocol settings are incompatible: ' + name)
152
+ hashes[name] = hashlib.sha256(data).hexdigest()
153
+ return {'boundary': 'offline installed executable protocol export', 'schemas': hashes,
154
+ 'modelTurnsStarted': False}