@topolo/cli 0.13.0 → 0.13.2

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.
@@ -24,6 +24,7 @@ import urllib.parse
24
24
  import uuid
25
25
 
26
26
  from .storage import Library,PackError,atomic_json,canonical,read_json
27
+ from .execution import load_gate, qualify_protocol
27
28
 
28
29
  def socket_path(lib):
29
30
  directory=pathlib.Path('/tmp')/('topolo-agent-pack-'+hashlib.sha256(str(lib.root).encode()).hexdigest()[:16])
@@ -68,11 +69,16 @@ class BackendProxy:
68
69
  self.pending=set();self.active_turns=set();self.initialized=False
69
70
  self.quiesced=False;self.output_task=None;self.closed=False
70
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()
71
74
 
72
75
  async def start_child(self):
73
76
  active=self.lib.active()
74
77
  if not active.get('runtime'): raise PackError('No selected managed runtime')
75
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
76
82
  env['CODEX_HOME']=str(pathlib.Path(self.runtime)/'home');env['CODEX_CLI_PATH']=self.real
77
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)
78
84
  self.output_task=asyncio.create_task(self.output())
@@ -80,6 +86,23 @@ class BackendProxy:
80
86
  async def output(self):
81
87
  while line:=await self.child.stdout.readline():
82
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})
83
106
  if 'id' in value and ('result' in value or 'error' in value):
84
107
  self.pending.discard(str(value['id']))
85
108
  if 'result' in value and isinstance(value['result'],dict) and 'userAgent' in value['result']: self.initialized=True
@@ -100,10 +123,34 @@ class BackendProxy:
100
123
  raise PackError('Owned backend did not stop; no storage switch committed')
101
124
  if self.output_task: await self.output_task
102
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
+
103
148
  def status(self):
104
149
  return {'pid':os.getpid(),'childPid':self.child.pid if self.child else None,'desktopPid':os.getppid(),'runtime':self.runtime,
105
150
  'quiesced':self.quiesced,'initialized':self.initialized,
106
- 'activeTurns':sorted(self.active_turns),'pendingRequests':len(self.pending)}
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}
107
154
 
108
155
  async def control(self,reader,writer):
109
156
  try:
@@ -141,13 +188,7 @@ class BackendProxy:
141
188
  await asyncio.get_running_loop().connect_read_pipe(lambda:protocol,sys.stdin.buffer)
142
189
  while line:=await reader.readline():
143
190
  value=json.loads(line)
144
- if self.quiesced:
145
- # No new model work may start between quiesce and reconnect.
146
- if 'id' in value:
147
- sys.stdout.buffer.write(canonical({'id':value['id'],'error':{'code':-32000,'message':'Pack switch in progress; reconnecting backend'}})+b'\n');sys.stdout.buffer.flush()
148
- continue
149
- if 'id' in value and 'method' in value: self.pending.add(str(value['id']))
150
- self.child.stdin.write(line);await self.child.stdin.drain()
191
+ await self.forward(value)
151
192
  finally:
152
193
  await self.stop_child();server.close();await server.wait_closed()
153
194
  record=read_json(self.lib.root/'live-bridge.json',{})
@@ -0,0 +1,92 @@
1
+ """Portable per-role execution intent, distinct from harness-applied settings.
2
+
3
+ Rendering a policy never changes a session, permission grant, or pack revision.
4
+ Adapters must independently apply and read back every required setting.
5
+ """
6
+ import copy
7
+
8
+ from .storage import PackError, component_key
9
+
10
+
11
+ GROUPS = {'specialists': 'Topolo Specialists', 'operators': 'Topolo Operators',
12
+ 'operator-skills': 'Operator Skills'}
13
+ DEEP_ROLES = {'product-direction-01', 'product-direction-02', 'system-architecture-01',
14
+ 'system-architecture-02', 'architecture', 'security-permissions-review',
15
+ 'adversarial-qa', 'incident-triage', 'release-recovery', 'execution-readiness',
16
+ 'privacy-and-agreement-review', 'workflow-integration-specialist',
17
+ 'financial-evidence-specialist', 'accounting-evidence-review',
18
+ 'access-and-privacy'}
19
+ BUILD_ROLES = {'build-01', 'build-02', 'build-03', 'developer-environment-steward',
20
+ 'platform-package-steward', 'worktree-retirement'}
21
+ PROFILES = {
22
+ 'intake': {'tier': 'reasoning', 'effort': 'medium', 'localAccess': 'read-only'},
23
+ 'analysis': {'tier': 'reasoning', 'effort': 'high', 'localAccess': 'read-only'},
24
+ 'review': {'tier': 'workhorse', 'effort': 'high', 'localAccess': 'read-only'},
25
+ 'build': {'tier': 'workhorse', 'effort': 'high', 'localAccess': 'workspace-write'},
26
+ 'operator': {'tier': 'workhorse', 'effort': 'medium', 'localAccess': 'workspace-write'},
27
+ }
28
+ MODELS = {'codex': {'reasoning': 'gpt-6-astra', 'workhorse': 'gpt-6.1-sol'},
29
+ 'claude': {'reasoning': 'claude-opus-5-5', 'workhorse': 'claude-sonnet-5-5'}}
30
+
31
+
32
+ def role_profile(agent):
33
+ explicit = agent.get('executionProfile')
34
+ if explicit is not None:
35
+ if explicit not in PROFILES:
36
+ raise PackError('Unknown execution profile for ' + component_key(agent))
37
+ return explicit
38
+ if agent['set'] == 'intakes':
39
+ return 'intake'
40
+ if agent['id'] in DEEP_ROLES:
41
+ return 'analysis'
42
+ if agent['id'] in BUILD_ROLES:
43
+ return 'build'
44
+ if agent['set'] == 'specialists':
45
+ return 'review'
46
+ return 'operator'
47
+
48
+
49
+ def settings(agent, harness):
50
+ if harness not in MODELS:
51
+ raise PackError('Execution settings adapter is not qualified: ' + harness)
52
+ profile = role_profile(agent)
53
+ spec = PROFILES[profile]
54
+ model = MODELS[harness][spec['tier']]
55
+ if harness == 'codex':
56
+ native = {'model': model, 'model_reasoning_effort': spec['effort'],
57
+ 'approval_policy': 'on-request', 'approvals_reviewer': 'user',
58
+ 'sandbox_mode': spec['localAccess'],
59
+ 'sandbox_workspace_write': {'network_access': False, 'writable_roots': [],
60
+ 'exclude_slash_tmp': True, 'exclude_tmpdir_env_var': True},
61
+ 'apps': {'_default': {'default_tools_approval_mode': 'writes'}}}
62
+ else:
63
+ native = {'model': model, 'effortLevel': spec['effort'],
64
+ 'permissions': {'defaultMode': 'acceptEdits' if profile == 'build' else 'default'}}
65
+ return {'profile': profile, 'nativeSettings': native,
66
+ 'localAccessIntent': spec['localAccess'], 'applied': False,
67
+ # Claude Manual is an approval mode, not a read-only filesystem sandbox.
68
+ 'requiresAdditionalLocalConfinement': harness == 'claude' and spec['localAccess'] == 'read-only'}
69
+
70
+
71
+ def render(manifest, revision, agents, harnesses=('codex', 'claude')):
72
+ rows = []
73
+ for agent in agents:
74
+ rows.append({'key': component_key(agent), 'title': agent['title'],
75
+ 'sidebar': {'group': GROUPS.get(agent['set'], agent['set']),
76
+ 'pinned': agent.get('pinned', False)},
77
+ 'harnesses': {h: settings(agent, h) for h in harnesses}})
78
+ return {'format': 'topolo-agent-execution-policy', 'schemaVersion': 1,
79
+ 'pack': manifest['packId'], 'sourceRevision': revision, 'agents': rows,
80
+ 'applied': False,
81
+ 'contextPolicy': {'load': ['role charter', 'current task', 'relevant retrieved memory'],
82
+ 'sharedMemory': 'retrieve by task and user or organization scope',
83
+ 'history': 'preserve, do not copy between roles',
84
+ 'skills': 'load on demand', 'toolResults': 'bounded relevant excerpts',
85
+ 'idleModelTurns': False, 'backgroundPolling': False,
86
+ 'handoffs': 'one concise outcome with evidence references'},
87
+ 'verification': {'required': ['session identity', 'sidebar membership', 'pins',
88
+ 'model', 'effort', 'permissions', 'context identity'],
89
+ 'unsupportedSettings': 'refuse activation; never silently substitute'},
90
+ 'authority': {'externalActions': 'existing human authorization and platform access controls',
91
+ 'packMembershipGrantsAuthority': False},
92
+ 'profiles': copy.deepcopy(PROFILES)}