@topolo/cli 0.13.3 → 0.13.4

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.
@@ -492,7 +492,19 @@ authorization remains separate from local command sandboxing.
492
492
  Managed bridge status reports requested policy and which resumes have current
493
493
  native readback. It does not claim an inference test or independent app-tool
494
494
  approval readback. Existing runtimes without this policy report not-configured
495
- until explicitly rebuilt. Source fixtures do not establish live desktop or
495
+ until explicit `permissions ... --apply` adoption on the existing idle connection.
496
+ Adoption validates the complete selected composition and current source revision,
497
+ qualifies the installed protocol and native model catalog, and verifies original
498
+ thread identities and workspaces before native permission changes. Legacy model
499
+ and effort values are reviewed source-role intent, qualified only by native
500
+ resume responses. Existing execution-policy selections are preserved. The
501
+ operation compares charter and transcript inventories and retains partial or
502
+ uncertain effects for read-only reconciliation; it never recreates threads.
503
+
504
+ The private token-authenticated bridge also supports `owned-roster-read` with
505
+ no parameters. It sends only fixed native thread metadata and model-catalog
506
+ reads for the full selected roster. Its result explicitly does not establish
507
+ effective permissions, models or app-tool approval behavior. Source fixtures do not establish live desktop or
496
508
  three-environment shared-memory acceptance.
497
509
 
498
510
  ### Organization consent and intake permission routing
@@ -335,6 +335,7 @@ def parser():
335
335
  if command=='connect':
336
336
  q.add_argument('--roles',required=True);q.add_argument('--agent-workspace',required=True);q.add_argument('--bytes-workspace',required=True)
337
337
  q.add_argument('--ownership',choices=['user','organization'],required=True);q.add_argument('--bytes-scope',choices=['private','org'],required=True);q.add_argument('--instance')
338
+ q.add_argument('--reconcile-local-context',action='store_true',help='Explicitly reconcile a recorded partial context attachment; never replays cloud writes')
338
339
  if command=='remember':
339
340
  q.add_argument('--content',required=True);q.add_argument('--kind',choices=['instruction','knowledge','durable'],required=True)
340
341
  q.add_argument('--provenance',required=True);q.add_argument('--visibility',choices=['private','shared'],default='private')
@@ -359,13 +360,18 @@ def main(argv=None):
359
360
  harness = argv[at + 1]
360
361
  del argv[at:at + 2]
361
362
  policy_command = 'policy' in argv or 'permissions' in argv
362
- if harness == 'claude' and not policy_command:
363
+ command_args=list(argv)
364
+ if command_args and command_args[0]=='--store':command_args=command_args[2:]
365
+ elif command_args and command_args[0].startswith('--store='):command_args=command_args[1:]
366
+ cloud_command=bool(command_args and command_args[0] in {'connect','remote-context','remember','artifact','artifact-get','remote-recover'})
367
+ if harness == 'claude' and not policy_command and not cloud_command:
363
368
  from . import claude
364
369
  return claude.main(argv)
365
370
  if harness not in {'codex', 'claude'}:
366
371
  print('topolo-agent-pack: Unsupported harness: ' + harness, file=sys.stderr)
367
372
  return 2
368
373
  args=parser().parse_args(argv)
374
+ args.harness=harness
369
375
  try:
370
376
  lib=Library(args.store)
371
377
  if args.command in {'connect','remote-context','remember','artifact','artifact-get','remote-recover'}:
@@ -17,16 +17,19 @@ from . import policy, permissions
17
17
  from .storage import PackError, atomic_json, component_key, read_json
18
18
 
19
19
 
20
- def compile_runtime(lib, components, runtime):
20
+ def prepare_runtime(lib, components, runtime):
21
+ """Validate existing role intent without installing a policy or changing history."""
21
22
  runtime = pathlib.Path(runtime)
22
23
  with closing(sqlite3.connect('file:' + str(runtime / 'home/state_5.sqlite') + '?mode=ro', uri=True)) as db:
23
24
  cwds = dict(db.execute('SELECT id,cwd FROM threads'))
24
- rows = []
25
+ rows = []; keys = set(); identities = set()
25
26
  for component in components:
26
27
  manifest, revision = lib.load(component['sourcePack'], component['revision'])
27
28
  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
+ if (len(matches) != 1 or component['threadId'] not in cwds or revision != component['revision'] or
30
+ component['key'] in keys or component['threadId'] in identities):
29
31
  raise PackError('Execution policy has no unique owned role: ' + component['key'])
32
+ keys.add(component['key']); identities.add(component['threadId'])
30
33
  consent = permissions.load(lib, matches[0]['pack'], manifest['agents']) if hasattr(lib, 'root') else None
31
34
  rows.append({'key': component['key'], 'threadId': component['threadId'],
32
35
  'sourceRevision': revision, 'cwd': cwds[component['threadId']],
@@ -38,7 +41,12 @@ def compile_runtime(lib, components, runtime):
38
41
  'roles': rows, 'applied': False, 'modelTurnsStarted': False}
39
42
  # Construct/validate fully before installing intent in a staged runtime.
40
43
  ExecutionGate(document)
41
- atomic_json(runtime / 'execution-policy.json', document)
44
+ return document
45
+
46
+
47
+ def compile_runtime(lib, components, runtime):
48
+ document = prepare_runtime(lib, components, runtime)
49
+ atomic_json(pathlib.Path(runtime) / 'execution-policy.json', document)
42
50
  return document
43
51
 
44
52
 
@@ -5,6 +5,7 @@ or another Codex profile. The renderer bridge is available only in the managed
5
5
  profile explicitly launched with local debugging enabled.
6
6
  """
7
7
  import asyncio
8
+ from contextlib import closing
8
9
  import base64
9
10
  import hashlib
10
11
  import json
@@ -24,7 +25,7 @@ import urllib.parse
24
25
  import uuid
25
26
 
26
27
  from .storage import Library,PackError,atomic_json,canonical,read_json
27
- from .execution import ExecutionGate, load_gate, qualify_protocol
28
+ from .execution import ExecutionGate, load_gate, prepare_runtime, qualify_protocol
28
29
  from .approvals import ApprovalRouter
29
30
 
30
31
  def permission_settings(agent, current, consent):
@@ -45,7 +46,7 @@ def request(lib,operation,params=None):
45
46
  record=read_json(lib.root/'live-bridge.json',{})
46
47
  if not record: raise PackError('This desktop profile has no live bridge. Open it through topolo-agent-pack first.')
47
48
  with socket.socket(socket.AF_UNIX,socket.SOCK_STREAM) as connection:
48
- connection.settimeout(3 if operation=='status' else 60 if operation=='permissions-configure' else 20)
49
+ connection.settimeout(3 if operation=='status' else 60 if operation in {'permissions-configure','owned-roster-read'} else 20)
49
50
  try: connection.connect(record['socket'])
50
51
  except OSError as exc: raise PackError('Managed live bridge is unavailable: '+str(exc)) from exc
51
52
  connection.sendall(canonical({'token':record['token'],'operation':operation,**({'params':params} if params is not None else {})})+b'\n')
@@ -83,6 +84,7 @@ class BackendProxy:
83
84
  self.approvals=None;self.native_calls={};self.notifications=set()
84
85
  self.permission_configuration=False
85
86
  self.permission_batch=None
87
+ self._permission_configuration_inputs=None
86
88
 
87
89
  async def start_child(self):
88
90
  active=self.lib.active()
@@ -153,6 +155,10 @@ class BackendProxy:
153
155
  if self.permission_configuration and value.get('method') in {'thread/resume','turn/start'}:
154
156
  sys.stdout.buffer.write(canonical({'id':value.get('id'),'error':{'code':-32000,'message':'Owned permission configuration in progress'}})+b'\n')
155
157
  sys.stdout.buffer.flush();return
158
+ if value.get('method') in {'thread/resume','turn/start'} and (pathlib.Path(self.runtime)/'permissions-operation.json').exists():
159
+ sys.stdout.buffer.write(canonical({'id':value.get('id'),'error':{'code':-32000,
160
+ 'message':'Unresolved native permission operation requires reconciliation before execution'}})+b'\n')
161
+ sys.stdout.buffer.flush();return
156
162
  if self.quiesced:
157
163
  if 'id' in value:
158
164
  sys.stdout.buffer.write(canonical({'id':value['id'],'error':{'code':-32000,'message':'Pack switch in progress; reconnecting backend'}})+b'\n');sys.stdout.buffer.flush()
@@ -181,8 +187,14 @@ class BackendProxy:
181
187
  identity='topolo-relay-'+uuid.uuid4().hex
182
188
  request={'id':identity,'method':method,'params':params}
183
189
  if method in {'thread/resume','turn/start'}:
190
+ if not self.permission_configuration and (pathlib.Path(self.runtime)/'permissions-operation.json').exists():
191
+ raise PackError('Unresolved native permission operation requires reconciliation before execution')
184
192
  await self.qualify_organization(params['threadId'])
185
193
  await asyncio.wait_for(self.catalog_ready.wait(),10)
194
+ if self.permission_configuration:
195
+ if self._permission_configuration_inputs is None:
196
+ raise PackError('No owned permission configuration snapshot; native settings were not sent')
197
+ self.configuration_current(*self._permission_configuration_inputs)
186
198
  request=self.execution.prepare(request)
187
199
  future=asyncio.get_running_loop().create_future();self.native_calls[identity]=future;self.pending.add(identity)
188
200
  try:
@@ -221,68 +233,200 @@ class BackendProxy:
221
233
  authorize_native_scope(proof)
222
234
  role['organizationPolicyProof']=proof
223
235
 
236
+ def existing_runtime_intent(self):
237
+ """Full existing composition only; never use a new composition as migration."""
238
+ active=self.lib.active()
239
+ components=active.get('components',[])
240
+ if str(active.get('runtime'))!=str(self.runtime) or not components:
241
+ raise PackError('Selected runtime has no complete owned source roster')
242
+ document=prepare_runtime(self.lib,components,self.runtime)
243
+ roster=read_json(pathlib.Path(self.runtime)/'agents.json')['agents']
244
+ def identities(rows):
245
+ values=[('/'.join([r['pack'],r['set'],r['id']]),r['threadId']) for r in rows]
246
+ if len(set(values))!=len(values):
247
+ raise PackError('Duplicate runtime roster membership')
248
+ return set(values)
249
+ declared={(r['key'],r['threadId']) for r in document['roles']}
250
+ with closing(sqlite3.connect('file:'+str(pathlib.Path(self.runtime)/'home/state_5.sqlite')+'?mode=ro',uri=True)) as db:
251
+ native_ids=[r[0] for r in db.execute('SELECT id FROM threads')]
252
+ if len(native_ids)!=len(declared) or set(native_ids)!={tid for _,tid in declared}:
253
+ raise PackError('Native runtime membership differs from full selected composition')
254
+ if identities(roster)!=declared:
255
+ raise PackError('Runtime roster differs from selected source membership')
256
+ for component in components:
257
+ current,revision=self.lib.load(component['sourcePack'])
258
+ if revision!=component['revision'] or (component['key'],component['threadId']) not in identities(current['agents']):
259
+ raise PackError('Current source revision or role identity differs from selected runtime')
260
+ return document
261
+
262
+ def preservation(self,roles):
263
+ paths={pathlib.Path(self.runtime)/'agents.json'}
264
+ for role in roles:
265
+ cwd=pathlib.Path(role['cwd'])
266
+ if not (cwd/'AGENTS.md').is_file():
267
+ raise PackError('Owned role starting context is missing')
268
+ paths.update(cwd.rglob('*.md'))
269
+ home=pathlib.Path(self.runtime)/'home'
270
+ for name in ['sessions','archived_sessions']:
271
+ paths.update((home/name).rglob('*.jsonl'))
272
+ for path in paths:
273
+ if path.is_symlink() or not path.is_file():
274
+ raise PackError('Context and transcript preservation requires regular owned files')
275
+ return {str(p):hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted(paths)}
276
+
277
+ async def read_catalog(self):
278
+ rows=[];cursors=set();models=set();cursor=None
279
+ while True:
280
+ result=await self.call_native('model/list',{'includeHidden':True,**({'cursor':cursor} if cursor else {})})
281
+ for row in result['data']:
282
+ if not isinstance(row.get('model'),str) or not row['model'] or row['model'] in models:
283
+ raise PackError('Native model catalog identity is unqualified')
284
+ models.add(row['model']);rows.append(row)
285
+ cursor=result.get('nextCursor')
286
+ if not cursor:return {'data':rows}
287
+ if not isinstance(cursor,str) or cursor in cursors or len(cursors)>=20:
288
+ raise PackError('Native model catalog pagination is unqualified')
289
+ cursors.add(cursor)
290
+
291
+ async def owned_roster_read(self,params):
292
+ """Finite token-authenticated metadata reads, not an arbitrary RPC dispatcher."""
293
+ if params or self.permission_configuration or self.quiesced or not self.initialized or self.active_turns or self.pending or self.notifications:
294
+ raise PackError('Owned read requires an idle initialized connection and no caller overrides')
295
+ self.permission_configuration=True
296
+ try:
297
+ document=self.existing_runtime_intent();rows=[]
298
+ protocol=qualify_protocol(self.real)
299
+ for role in document['roles']:
300
+ state=await self.call_native('thread/read',{'threadId':role['threadId'],'includeTurns':False})
301
+ thread=state.get('thread',{})
302
+ if thread.get('id')!=role['threadId'] or thread.get('cwd')!=role['cwd']:
303
+ raise PackError('Native role identity differs from owned roster')
304
+ rows.append({'key':role['key'],'threadId':role['threadId'],'cwd':role['cwd'],
305
+ 'status':thread.get('status',{}).get('type')})
306
+ catalog=await self.read_catalog()
307
+ return {'state':'native-owned-roster-read','roles':rows,
308
+ 'models':[{'model':r['model'],'supportedReasoningEfforts':r['supportedReasoningEfforts']} for r in catalog['data']],
309
+ 'protocol':protocol,'classification':'native metadata and account catalog; role settings remain intent',
310
+ 'effectiveSettingsVerified':False,'modelTurnsStarted':False}
311
+ finally:self.permission_configuration=False
312
+
313
+ def qualification_inputs(self,roles):
314
+ """Cheap current owned inputs; never cache a changed selection or consent."""
315
+ from . import permissions
316
+ active=self.lib.active();sources={}
317
+ for name in sorted({c['sourcePack'] for c in active.get('components',[])}):
318
+ manifest,revision=self.lib.load(name)
319
+ sources[name]={'revision':revision,'consent':permissions.load(self.lib,name,manifest['agents'])}
320
+ with closing(sqlite3.connect('file:'+str(pathlib.Path(self.runtime)/'home/state_5.sqlite')+'?mode=ro',uri=True)) as db:
321
+ native_cwds=sorted(db.execute('SELECT id,cwd FROM threads'))
322
+ return json.loads(canonical({'selected':{'runtime':active.get('runtime'),'components':active.get('components')},
323
+ 'childPid':self.child.pid,'ownedRuntime':str(self.runtime),
324
+ 'sources':sources,'runtimeRoster':read_json(pathlib.Path(self.runtime)/'agents.json'),
325
+ 'nativeMembershipAndCwds':native_cwds,'contextTranscriptHashes':self.preservation(roles)}))
326
+
327
+ def configuration_current(self,inputs,roles):
328
+ if (self.quiesced or self.closed or not self.child or getattr(self.child,'returncode',None) is not None or
329
+ self.active_turns or self.pending or self.notifications or (self.approvals and self.approvals.pending)):
330
+ raise PackError('Native work or approval arrived during configuration; reconcile pending requests and any recorded effects')
331
+ if inputs is not None and self.qualification_inputs(roles)!=inputs:
332
+ raise PackError('Selected runtime, source, consent, context or transcript changed during qualification; reconcile any recorded effects')
333
+
224
334
  async def configure_permissions(self,pack):
225
- """Configure the existing owned child, without launching another server."""
335
+ """Explicit legacy adoption or transition on the SAME existing owned child."""
226
336
  from . import permissions
227
- if self.permission_configuration or self.quiesced or not self.execution or self.active_turns or self.pending:
337
+ if self.permission_configuration or self.quiesced or self.active_turns or self.pending or self.notifications or (not self.execution and not self.initialized):
228
338
  raise PackError('Owned native connection must be initialized, idle and settled')
229
339
  marker=pathlib.Path(self.runtime)/'permissions-operation.json'
230
340
  if marker.exists():
231
341
  raise PackError('Unresolved native permissions operation requires read-only reconciliation')
232
342
  if self.approvals and self.approvals.pending:
233
343
  raise PackError('Native approvals remain pending; preserve their owning connection')
234
- manifest,_=self.lib.load(pack);consent=permissions.load(self.lib,pack,manifest['agents'])
235
- if not consent:
236
- raise PackError('Explicit organization-bound installation consent required')
237
- roles=list(self.execution.roles.values());wanted=[]
238
- by_key={r['key']:r for r in roles}
239
- for agent in manifest['agents']:
240
- key='/'.join([agent['pack'],agent['set'],agent['id']]);old=by_key.get(key)
241
- if not old or old['threadId']!=agent['threadId']:
242
- raise PackError('Complete pack must match the live owned roster')
243
- row={**old,'settings':permission_settings(agent,old['settings'],consent),
244
- 'installationConsent':consent,'intakeKey':consent['intakeRoutes'][key],'contextKey':consent['contextKey']}
245
- wanted.append(row)
246
- changed={r['threadId']:r for r in wanted}
247
- candidate=ExecutionGate({'format':'topolo-codex-runtime-execution','schemaVersion':1,
248
- 'roles':[changed.get(r['threadId'],r) for r in roles]})
249
- candidate.catalog=self.execution.catalog.copy()
250
- # Complete ownership/idle qualification precedes the first native effect.
251
344
  self.permission_configuration=True
345
+ legacy=self.execution is None;preservation=None;inputs=None
252
346
  try:
347
+ if legacy:
348
+ if (pathlib.Path(self.runtime)/'execution-policy.json').exists():
349
+ raise PackError('Existing policy must be qualified; not a legacy adoption')
350
+ document=self.existing_runtime_intent()
351
+ roles=document['roles'];preservation=self.preservation(roles)
352
+ inputs=self.qualification_inputs(roles)
353
+ provenance='reviewed source-role adoption intent; qualified only by native resume acknowledgements'
354
+ else:
355
+ roles=list(self.execution.roles.values())
356
+ provenance='preserved owned execution-policy model and effort'
357
+ manifest,_=self.lib.load(pack);consent=permissions.load(self.lib,pack,manifest['agents'])
358
+ if not consent:
359
+ raise PackError('Explicit organization-bound installation consent required')
360
+ wanted=[];by_key={r['key']:r for r in roles}
361
+ for agent in manifest['agents']:
362
+ key='/'.join([agent['pack'],agent['set'],agent['id']]);old=by_key.get(key)
363
+ if not old or old['threadId']!=agent['threadId']:
364
+ raise PackError('Complete pack must match the live owned roster')
365
+ wanted.append({**old,'settings':permission_settings(agent,old['settings'],consent),
366
+ 'installationConsent':consent,'intakeKey':consent['intakeRoutes'][key],'contextKey':consent['contextKey']})
367
+ if legacy and len(wanted)!=len(roles):
368
+ raise PackError('Legacy adoption requires the full selected composition')
369
+ changed={r['threadId']:r for r in wanted}
370
+ candidate=ExecutionGate({'format':'topolo-codex-runtime-execution','schemaVersion':1,
371
+ 'roles':[changed.get(r['threadId'],r) for r in roles]})
372
+ # Capture original native requests BEFORE the first awaited legacy read.
373
+ # Existing pending request generation/body custody always stays on this router.
374
+ routing=ApprovalRouter(pathlib.Path(self.runtime)/'approval-inbox',list(candidate.roles.values()),uuid.uuid4().hex)
375
+ if not self.approvals:self.approvals=routing
376
+ self._permission_configuration_inputs=(inputs,roles)
377
+ if legacy:
378
+ self.protocol=qualify_protocol(self.real)
379
+ candidate_catalog=await self.read_catalog()
380
+ self.configuration_current(inputs,roles)
381
+ candidate.set_catalog(candidate_catalog);self.catalog_ready.set()
382
+ else:candidate.catalog=self.execution.catalog.copy()
253
383
  for role in wanted:
254
384
  state=await self.call_native('thread/read',{'threadId':role['threadId'],'includeTurns':False})
385
+ self.configuration_current(inputs,roles)
255
386
  thread=state.get('thread',{})
256
387
  if thread.get('id')!=role['threadId'] or thread.get('cwd')!=role['cwd'] or thread.get('status',{}).get('type') not in {'idle','notLoaded'}:
257
388
  raise PackError('Native role identity or idle status differs')
258
389
  if role['settings']['model_reasoning_effort'] not in candidate.catalog.get(role['settings']['model'],set()):
259
390
  raise PackError('Native account lacks the preserved model/effort')
260
- # Validate the new routing graph before committing any permission intent.
261
- ApprovalRouter(pathlib.Path(self.runtime)/'approval-inbox',list(candidate.roles.values()),uuid.uuid4().hex)
262
- atomic_json(marker,{'state':'prepared','pack':pack,'before':roles,'requested':wanted})
391
+ self.configuration_current(inputs,roles)
392
+ operation={'pack':pack,'before':roles,'requested':wanted,'modelEffortProvenance':provenance,
393
+ 'beforeSettingsClassification':'source intent only' if legacy else 'owned execution policy intent',
394
+ 'protocol':self.protocol,'contextTranscriptHashes':preservation,'qualificationInputs':inputs}
395
+ atomic_json(marker,{**operation,'state':'prepared'})
263
396
  self.execution=candidate
264
397
  await self.qualify_organization(wanted[0]['threadId'])
398
+ self.configuration_current(inputs,roles)
265
399
  self.permission_batch={'consent':consent,'proof':candidate.roles[wanted[0]['threadId']]['organizationPolicyProof'],
266
400
  'deadline':time.monotonic()+45}
267
- atomic_json(marker,{'state':'may-have-effects','pack':pack,'before':roles,'requested':wanted})
401
+ atomic_json(marker,{**operation,'state':'may-have-effects'})
268
402
  for role in wanted:
403
+ self.configuration_current(inputs,roles)
269
404
  await self.call_native('thread/resume',{'threadId':role['threadId']})
270
- atomic_json(marker,{'state':'may-have-effects','pack':pack,'before':roles,'requested':wanted,
405
+ # First persist ACK progress, then refuse late work/drift. Do not lose a final ACK.
406
+ atomic_json(marker,{**operation,'state':'may-have-effects',
271
407
  'verifiedThreadIds':sorted(candidate.verified)})
408
+ self.configuration_current(inputs,roles)
409
+ self.configuration_current(inputs,roles)
272
410
  document={'format':'topolo-codex-runtime-execution','schemaVersion':1,'roles':list(candidate.roles.values()),
273
411
  'applied':False,'modelTurnsStarted':False}
274
412
  atomic_json(pathlib.Path(self.runtime)/'execution-policy.json',document)
275
- self.approvals=ApprovalRouter(pathlib.Path(self.runtime)/'approval-inbox',list(candidate.roles.values()),uuid.uuid4().hex)
413
+ # No await between the final custody fence and committing the routing graph.
414
+ # Preserve the physical connection's current router and generation.
415
+ self.approvals.roles=routing.roles;self.approvals.by_key=routing.by_key
276
416
  result={'state':'native-permission-resumes-verified','pack':pack,'verifiedAgents':len(wanted),
277
417
  'modelTurnsStarted':False,'historyChanged':False,'appToolApprovalReadbackVerified':False,
278
- 'intakeRoutes':len(wanted),'nativeApprovalTransport': 'owned-live-connection'}
418
+ 'intakeRoutes':len(wanted),'nativeApprovalTransport': 'owned-live-connection',
419
+ 'modelEffortProvenance':provenance,'legacyRuntimeAdopted':legacy,
420
+ 'contextTranscriptPreservationVerified':preservation is not None}
279
421
  atomic_json(marker,{**result,'verifiedThreadIds':sorted(candidate.verified)})
280
422
  atomic_json(pathlib.Path(self.runtime)/'permissions-proof.json',result);marker.unlink()
281
423
  return result
282
- finally:self.permission_configuration=False;self.permission_batch=None
424
+ finally:
425
+ self._permission_configuration_inputs=None
426
+ self.permission_configuration=False;self.permission_batch=None
283
427
 
284
428
  def schedule_notification(self,record):
285
- if (record['key'] in self.notifications or record['notificationState'] not in
429
+ if (self.permission_configuration or record['key'] in self.notifications or record['notificationState'] not in
286
430
  {'not-delivered','awaiting-idle-intake'}):return
287
431
  self.notifications.add(record['key'])
288
432
  async def deliver():
@@ -319,18 +463,24 @@ class BackendProxy:
319
463
  if not secrets.compare_digest(message.get('token',''),self.token): raise PackError('Live bridge authentication failed')
320
464
  op=message.get('operation')
321
465
  if op=='quiesce':
466
+ if self.permission_configuration:
467
+ raise PackError('Permission configuration in progress; preserve the owning native connection')
322
468
  if self.active_turns: raise PackError('Outgoing agents have active turns. Wait for their current turns; Codex can stay open.')
323
469
  if self.pending: raise PackError('Backend requests are in flight. Retry the switch; Codex can stay open.')
324
470
  self.quiesced=True
325
471
  await self.stop_child()
326
472
  elif op=='resume':
327
473
  if self.quiesced: raise PackError('Use the desktop reconnect action to resume a quiesced bridge')
474
+ elif op=='owned-roster-read':
475
+ result=await self.owned_roster_read(message.get('params') or {})
328
476
  elif op=='permissions-configure':
329
477
  result=await self.configure_permissions((message.get('params') or {})['pack'])
330
478
  elif op in {'approval-list','approval-details','approval-respond'}:
331
479
  if not self.approvals:
332
480
  raise PackError('This connection has no qualified intake routing')
333
481
  params=message.get('params') or {}
482
+ if op=='approval-respond' and self.permission_configuration:
483
+ raise PackError('Permission configuration in progress; original native request remains pending')
334
484
  if op=='approval-list':result={'requests':self.approvals.records()}
335
485
  else:
336
486
  entry=self.approvals.live(params['key'],params['intakeThreadId'],params['fingerprint'])
@@ -340,13 +490,15 @@ class BackendProxy:
340
490
  raise PackError('Recorded human decision required; never auto-approve')
341
491
  reply=self.approvals.response(params['key'],params['intakeThreadId'],params['fingerprint'],params['result'])
342
492
  if reply['result'].get('decision')=='accept':
493
+ if not self.execution:
494
+ raise PackError('Native approval requires qualified owned execution policy; original request remains pending')
343
495
  await self.qualify_organization(entry['record']['sourceThreadId'])
344
496
  self.approvals.live(params['key'],params['intakeThreadId'],params['fingerprint'])
345
497
  self.approvals.sending(reply)
346
498
  self.child.stdin.write(canonical(reply)+b'\n');await self.child.stdin.drain();self.approvals.settled(reply)
347
499
  result={'state':'native-response-forwarded','operationEffectVerified':False}
348
500
  elif op!='status': raise PackError('Unknown live bridge operation')
349
- if op not in {'approval-list','approval-details','approval-respond','permissions-configure'}:result=self.status()
501
+ if op not in {'approval-list','approval-details','approval-respond','permissions-configure','owned-roster-read'}:result=self.status()
350
502
  except Exception as exc: result={'error':str(exc)}
351
503
  writer.write(canonical(result)+b'\n');await writer.drain();writer.close();await writer.wait_closed()
352
504
 
@@ -2,6 +2,9 @@
2
2
  import re,contextlib,fcntl,base64,hashlib,json,os,pathlib,shutil,subprocess,tempfile,uuid
3
3
  from .storage import PackError,atomic_json,canonical,read_json,safe_id
4
4
 
5
+ class ConnectionSourceRefused(PackError):
6
+ pass
7
+
5
8
  class Client:
6
9
  def __init__(self,environment,context,binary=None):
7
10
  if environment not in {'development','staging','production'}:
@@ -35,6 +38,8 @@ class Client:
35
38
  plan=self.run(['actions','plan',*common,'--confirm'])
36
39
  if not plan.get('executable'):
37
40
  raise PackError('Topolo plan is not executable')
41
+ if action=='app_topolo_agent.agent_packs.connect' and getattr(self,'_pack_connect_scope',None) is not None:
42
+ qualify_connection_scope(self._pack_connect_scope)
38
43
  if upload:
39
44
  result=self.run(['actions','upload',*common,'--file',str(upload),'--content-type',input.get('type','application/octet-stream'),'--confirm'])
40
45
  else:result=self.run(['actions','call',*common,'--detail',*(['--confirm'] if write else [])])
@@ -123,9 +128,194 @@ def attach_runtime(lib,runtime):
123
128
  with os.fdopen(fd,'w') as f:f.write(text);f.flush();os.fsync(f.fileno())
124
129
  os.chmod(temp,0o600);os.replace(temp,guide)
125
130
 
131
+ def context_file(path,root,optional=False):
132
+ """Only regular owned files within a qualified local context root."""
133
+ path=pathlib.Path(path);root=pathlib.Path(root).resolve()
134
+ if not path.is_absolute() or not path.is_relative_to(root):
135
+ raise PackError('Context file is outside its owned root')
136
+ parent=path.parent
137
+ while parent.is_relative_to(root):
138
+ if parent.is_symlink() or not parent.is_dir() or parent.stat().st_uid!=os.getuid():
139
+ raise PackError('Context parent is not an owned regular directory')
140
+ if parent==root:break
141
+ parent=parent.parent
142
+ if path.is_symlink():
143
+ raise PackError('Context file must not be a symlink')
144
+ if not path.exists() and optional:return None
145
+ if not path.is_file() or path.stat().st_uid!=os.getuid() or path.stat().st_nlink!=1:
146
+ raise PackError('Context file must be an owned regular file')
147
+ return path.read_bytes()
148
+
149
+ def claude_context_plan(lib,name,manifest,revision):
150
+ from .claude import project_directory
151
+ safe_id(name);root=lib.root/'claude';state_path=root/'state.json'
152
+ state_bytes=context_file(state_path,lib.root);state=json.loads(state_bytes)
153
+ if state.get('schemaVersion')!=1 or state.get('harness')!='claude' or not isinstance(state.get('agents'),list):
154
+ raise PackError('Existing Claude roster required')
155
+ expected={name+'/'+a['set']+'/'+a['id']:a for a in manifest['agents']}
156
+ records=[r for r in state['agents'] if r.get('pack')==name]
157
+ if len(records)!=len(expected) or {r.get('key') for r in records}!=set(expected):
158
+ raise PackError('Complete existing Claude pack must match the captured roster')
159
+ ids=set();workspaces=set();rows=[];preserved={str(state_path):hashlib.sha256(state_bytes).hexdigest()}
160
+ config=pathlib.Path(os.environ.get('CLAUDE_CONFIG_DIR',str(pathlib.Path.home()/'.claude'))).expanduser().resolve()
161
+ for record in records:
162
+ key=record['key'];agent=expected[key];safe_id(agent['set']);safe_id(agent['id'])
163
+ workspace=root/'workspaces'/name/agent['set']/agent['id']
164
+ session=record.get('cliSessionId','')
165
+ try:session_ok=str(uuid.UUID(session))==session
166
+ except (ValueError,AttributeError,TypeError):session_ok=False
167
+ fields={'key':key,'pack':name,'set':agent['set'],'agent':agent['id'],'sourceRevision':revision,
168
+ 'sourceCodexThreadId':agent['threadId'],'workspace':str(workspace),'cliSessionId':session,'desktopSessionId':'local_'+session,
169
+ 'transcript':str(project_directory(config,workspace)/(session+'.jsonl'))}
170
+ if not session_ok or any(record.get(k)!=v for k,v in fields.items()) or session in ids or str(workspace) in workspaces:
171
+ raise PackError('Claude native identity, revision or workspace differs')
172
+ ids.add(session);workspaces.add(str(workspace))
173
+ identity_path=root/'identities'/(hashlib.sha256(key.encode()).hexdigest()+'.json')
174
+ identity_bytes=context_file(identity_path,lib.root);identity_record=json.loads(identity_bytes)
175
+ if any(identity_record.get(k)!=v for k,v in fields.items()):
176
+ raise PackError('Claude durable identity differs from its roster')
177
+ preserved[str(identity_path)]=hashlib.sha256(identity_bytes).hexdigest()
178
+ guide=workspace/'CLAUDE.md';body=context_file(guide,lib.root)
179
+ descriptor=workspace/'topolo-connection.json';old_descriptor=context_file(descriptor,lib.root,optional=True)
180
+ for file in workspace.rglob('*.md'):
181
+ if file!=guide:preserved[str(file)]=hashlib.sha256(context_file(file,lib.root)).hexdigest()
182
+ transcript=pathlib.Path(fields['transcript'])
183
+ preserved[str(transcript)]=hashlib.sha256(context_file(transcript,config/'projects')).hexdigest()
184
+ rows.append({'key':key,'logicalId':agent['set']+'/'+agent['id'],'nativeThreadId':record['desktopSessionId'],
185
+ 'guide':str(guide),'descriptor':str(descriptor),'beforeGuide':hashlib.sha256(body).hexdigest(),
186
+ 'beforeDescriptor':hashlib.sha256(old_descriptor).hexdigest() if old_descriptor is not None else None,
187
+ 'prefixHash':hashlib.sha256(body.split(b'\n<!-- topolo-continuity -->')[0]).hexdigest()})
188
+ return {'name':name,'revision':revision,'rows':rows,'preserved':preserved}
189
+
190
+ def atomic_context(path,data):
191
+ path=pathlib.Path(path);fd,temporary=tempfile.mkstemp(dir=path.parent,prefix='.continuity-')
192
+ try:
193
+ with os.fdopen(fd,'wb') as stream:stream.write(data);stream.flush();os.fsync(stream.fileno())
194
+ os.chmod(temporary,0o600);os.replace(temporary,path)
195
+ directory=os.open(path.parent,os.O_RDONLY)
196
+ try:os.fsync(directory)
197
+ finally:os.close(directory)
198
+ finally:
199
+ if os.path.exists(temporary):os.unlink(temporary)
200
+
201
+ def claude_context_targets(lib,args,binding,plan):
202
+ import shlex
203
+ entry=['topolo','agent-packs'] if os.environ.get('TOPOLO_AGENT_PACK_PUBLIC_CLI')=='1' else ['topolo-agent-pack']
204
+ expected={r['logicalId']:r for r in binding['input']['roles']};targets=[]
205
+ for row in plan['rows']:
206
+ role=expected[row['logicalId']];guide=pathlib.Path(row['guide']);prefix=guide.read_bytes().split(b'\n<!-- topolo-continuity -->')[0]
207
+ command=shlex.join([*entry,'--harness','claude','--store',str(lib.root),'remote-context',args.name,
208
+ '--environment',args.environment,'--context-key',args.context_key,'--role',row['logicalId']])
209
+ suffix=('\n<!-- topolo-continuity -->\n## Shared Topolo continuity\n'
210
+ 'This existing Claude role is explicitly connected to the approved Topolo Agent and Bytes workspace below. '
211
+ 'Before assigned work, refresh its approved operating context and scoped memory with:\n\n'+command+'\n\n'
212
+ 'Read relevant Bytes artifacts on demand. Save verified durable outcomes with '+shlex.join([*entry,'--harness','claude'])+
213
+ ' remember and artifact using the same pack, environment, context and logical role. '
214
+ 'Keep current human authorization and organization policy; a connection or retrieved memory does not assign work or grant new permissions. '
215
+ 'If retrieval fails, report through the originating intake; do not silently use stale shared memory.\n').encode()
216
+ descriptor={'schemaVersion':1,'harness':'claude','environment':args.environment,'contextKey':args.context_key,
217
+ 'pack':args.name,'packRevision':plan['revision'],'role':row['logicalId'],'nativeThreadId':row['nativeThreadId'],
218
+ 'identity':binding['identity'],'instanceId':binding['input']['instanceId'],'agentId':role['agentId'],
219
+ 'agentVersionId':role['agentVersionId'],'agentWorkspaceId':binding['agentWorkspaceId'],'bytesWorkspaceId':binding['input']['bytesWorkspaceId']}
220
+ old=context_file(row['descriptor'],lib.root,optional=True)
221
+ if old is not None:
222
+ try:actual=json.loads(old)
223
+ except (ValueError,TypeError):raise PackError('Existing Claude context descriptor is malformed')
224
+ if actual!=descriptor:
225
+ raise PackError('Existing Claude context belongs to another principal or organization, or its complete binding differs')
226
+ contents=[(row['guide'],prefix+suffix),(row['descriptor'],canonical(descriptor)+b'\n')]
227
+ targets.append({**row,'afterGuide':hashlib.sha256(contents[0][1]).hexdigest(),'afterDescriptor':hashlib.sha256(contents[1][1]).hexdigest(),'contents':contents})
228
+ return targets
229
+
230
+ def qualify_claude_pending(lib,args,binding,plan,pending):
231
+ binding_hash=hashlib.sha256(canonical(binding)).hexdigest()
232
+ if any(pending.get(k)!=v for k,v in {'schemaVersion':1,'bindingHash':binding_hash,'name':args.name,'revision':plan['revision']}.items()):
233
+ raise PackError('Claude attachment recovery binding differs; no new effects')
234
+ if pending.get('preserved')!=plan['preserved']:
235
+ raise PackError('Claude preserved context changed; no new effects')
236
+ targets=claude_context_targets(lib,args,binding,plan);old_targets=pending.get('targets')
237
+ if not isinstance(old_targets,list) or len(old_targets)!=len(targets) or any(any(old.get(k)!=new[k] for k in ['key','logicalId','nativeThreadId','guide','descriptor','prefixHash','afterGuide','afterDescriptor']) for old,new in zip(old_targets,targets)):
238
+ raise PackError('Claude attachment target identity differs; no new effects')
239
+ verified=pending.get('verifiedKeys');keys=[row['key'] for row in targets]
240
+ if not isinstance(verified,list) or verified!=keys[:len(verified)]:
241
+ raise PackError('Claude partial verification progress differs')
242
+ for old,current in zip(old_targets,targets):
243
+ if current['beforeGuide'] not in [old.get('beforeGuide'),old['afterGuide']] or current['beforeDescriptor'] not in [old.get('beforeDescriptor'),old['afterDescriptor']] or (current['key'] in verified and (current['beforeGuide']!=old['afterGuide'] or current['beforeDescriptor']!=old['afterDescriptor'])):
244
+ raise PackError('Claude partial target progress differs; no new effects')
245
+ return targets
246
+
247
+ def attach_claude(lib,args,binding,plan):
248
+ import shlex
249
+ path=binding_path(lib,args.name,args.environment,args.context_key)
250
+ journal=path.with_suffix('.claude-context.pending.json');receipt=path.with_suffix('.claude-context.verified.json')
251
+ binding_hash=hashlib.sha256(canonical(binding)).hexdigest()
252
+ pending=read_json(journal,{})
253
+ if pending and not getattr(args,'reconcile_local_context',False):
254
+ raise PackError('Partial Claude context attachment requires explicit --reconcile-local-context; no replay')
255
+ current_manifest,current_revision=lib.load(args.name)
256
+ current=claude_context_plan(lib,args.name,current_manifest,current_revision)
257
+ if current!=plan:
258
+ raise PackError('Claude context changed during connection qualification')
259
+ targets=claude_context_targets(lib,args,binding,plan)
260
+ stable={'schemaVersion':1,'bindingHash':binding_hash,'name':args.name,'revision':plan['revision'],'preserved':plan['preserved'],
261
+ 'targets':[{k:v for k,v in row.items() if k!='contents'} for row in targets]}
262
+ result={'state':'local-context-verified','harness':'claude','verifiedAgents':len(targets),'historyChanged':False,
263
+ 'nativeSettingsVerified':False,'nativeIdleVerified':False,'modelTurnsStarted':False,'bindingHash':binding_hash}
264
+ if not pending and all(row['beforeGuide']==row['afterGuide'] and row['beforeDescriptor']==row['afterDescriptor'] for row in targets):
265
+ if claude_context_plan(lib,args.name,*lib.load(args.name))!=plan:
266
+ raise PackError('Claude context changed before unchanged attachment receipt')
267
+ atomic_json(receipt,{**stable,**result,'verifiedKeys':[row['key'] for row in targets]})
268
+ return result
269
+ if pending:
270
+ if any(pending.get(k)!=stable[k] for k in ['schemaVersion','bindingHash','name','revision','preserved']):
271
+ raise PackError('Claude attachment recovery binding or preserved context differs')
272
+ old_targets=pending['targets']
273
+ if len(old_targets)!=len(targets) or any(any(old.get(k)!=new[k] for k in ['key','logicalId','nativeThreadId','guide','descriptor','prefixHash','afterGuide','afterDescriptor']) for old,new in zip(old_targets,targets)):
274
+ raise PackError('Claude attachment target identity differs')
275
+ stable['targets']=old_targets
276
+ else:atomic_json(journal,{**stable,'state':'prepared','verifiedKeys':[]})
277
+ verified=[]
278
+ for row,baseline in zip(targets,stable['targets']):
279
+ manifest,revision=lib.load(args.name);fresh=claude_context_plan(lib,args.name,manifest,revision)
280
+ if revision!=stable['revision'] or fresh['preserved']!=stable['preserved'] or [r['key'] for r in fresh['rows']]!=[r['key'] for r in plan['rows']]:
281
+ raise PackError('Claude source, identities, context or transcript changed during attachment')
282
+ for file,data in row['contents']:
283
+ old=context_file(file,lib.root,optional=file==row['descriptor']);digest=hashlib.sha256(old).hexdigest() if old is not None else None
284
+ before=baseline['beforeGuide'] if file==row['guide'] else baseline['beforeDescriptor']
285
+ after=baseline['afterGuide'] if file==row['guide'] else baseline['afterDescriptor']
286
+ if digest not in [before,after]:
287
+ raise PackError('Claude target changed; partial attachment retained for explicit reconciliation')
288
+ if digest!=after:atomic_context(file,data)
289
+ if hashlib.sha256(context_file(file,lib.root)).hexdigest()!=after:
290
+ raise PackError('Claude attachment read-back differs; retain partial journal')
291
+ verified.append(row['key']);atomic_json(journal,{**stable,'state':'partial','verifiedKeys':verified})
292
+ final=claude_context_plan(lib,args.name,*lib.load(args.name))
293
+ if final['preserved']!=stable['preserved']:
294
+ raise PackError('Claude preserved context or history changed before final receipt')
295
+ for row in stable['targets']:
296
+ if any(hashlib.sha256(context_file(row[file],lib.root)).hexdigest()!=row[after] for file,after in [('guide','afterGuide'),('descriptor','afterDescriptor')]):
297
+ raise PackError('Claude final attachment differs; retain partial journal')
298
+ atomic_json(receipt,{**stable,**result,'verifiedKeys':verified});journal.unlink()
299
+ return result
300
+
301
+ def qualify_connection_scope(scope):
302
+ lib,args,options,manifest_bytes,revision,roles_bytes,roles,claude_plan=scope
303
+ try:
304
+ current_manifest,current_revision=lib.load(args.name)
305
+ if vars(args)!=options or current_revision!=revision or canonical(current_manifest)!=manifest_bytes or pathlib.Path(args.roles).read_bytes()!=roles_bytes:
306
+ raise PackError('Pack source or request inputs changed')
307
+ # Manifest equality and Library.object's content hash bind instructions.
308
+ # Re-read each actual immutable object; missing/corrupt content refuses.
309
+ for agent in current_manifest['agents']:role_guide(lib,current_manifest,agent)
310
+ if claude_plan is not None and claude_context_plan(lib,args.name,current_manifest,current_revision)!=claude_plan:
311
+ raise PackError('Claude source, identities, context or history changed')
312
+ except (PackError,OSError,ValueError,TypeError) as error:
313
+ raise ConnectionSourceRefused('Pack source or native context changed before cloud dispatch; no mutating call') from error
314
+
126
315
  def connect(lib,args,client):
127
316
  manifest,revision=lib.load(args.name)
128
- selected=read_json(args.roles)
317
+ claude_plan=claude_context_plan(lib,args.name,manifest,revision) if getattr(args,'harness','codex')=='claude' else None
318
+ roles_bytes=pathlib.Path(args.roles).read_bytes();selected=json.loads(roles_bytes)
129
319
  expected={a['set']+'/'+a['id']:a for a in manifest['agents']}
130
320
  if not isinstance(selected,dict) or set(selected)!=set(expected):
131
321
  raise PackError('Roles file must map every logical set/agent exactly once')
@@ -136,6 +326,16 @@ def connect(lib,args,client):
136
326
  raise PackError('Each role requires only agentId and agentVersionId')
137
327
  roles.append({'logicalId':key,**remote,'instructions':role_guide(lib,manifest,a)})
138
328
  path=binding_path(lib,args.name,args.environment,args.context_key)
329
+ local_pending={}
330
+ if claude_plan is not None:
331
+ local_journals=list((lib.root/'connections'/args.name).rglob('*.claude-context.pending.json'))
332
+ expected_journal=path.with_suffix('.claude-context.pending.json')
333
+ if local_journals:
334
+ if len(local_journals)!=1 or local_journals[0]!=expected_journal:
335
+ raise PackError('Unresolved Claude attachment belongs to another environment or context; no new effects')
336
+ local_pending=json.loads(context_file(expected_journal,lib.root))
337
+ if not getattr(args,'reconcile_local_context',False):
338
+ raise PackError('Partial Claude context attachment requires explicit --reconcile-local-context; no replay')
139
339
  old=read_json(path,{})
140
340
  journal=path.with_suffix('.pending.json')
141
341
  pending=read_json(journal,{})
@@ -143,24 +343,60 @@ def connect(lib,args,client):
143
343
  payload={'instanceId':instance,'packId':manifest.get('startingContext',{}).get('templateId',manifest['packId']),
144
344
  'packVersion':manifest.get('startingContext',{}).get('templateVersion','1.0.0'),
145
345
  'ownershipScope':args.ownership,'bytesWorkspaceId':args.bytes_workspace,'bytesScope':args.bytes_scope,'roles':roles}
346
+ scope=(lib,args,dict(vars(args)),canonical(manifest),revision,roles_bytes,roles,claude_plan)
146
347
  owner=identity(client)
348
+ qualify_connection_scope(scope)
147
349
  journal=path.with_suffix('.pending.json')
148
350
  if journal.exists():
149
351
  pending=read_json(journal)
150
352
  if pending['input']!=payload or pending['identity']!=owner or pending.get('agentWorkspaceId')!=args.agent_workspace:
151
353
  raise PackError('Unresolved connection receipt; reconcile before changing the request')
152
- atomic_json(journal,{'input':payload,'identity':owner,'revision':revision,'agentWorkspaceId':args.agent_workspace})
153
- result=client.action('app_topolo_agent.agent_packs.connect',payload,args.agent_workspace,write=True)
154
- remote=result['connection'];readback=client.action('app_topolo_agent.agent_packs.get',{'instanceId':instance},args.agent_workspace)['connection']
354
+ if claude_plan is not None and pending:
355
+ raise PackError('Unresolved cloud connection requires remote-recover before Claude attachment; no cloud replay')
356
+ if old and old.get('identity')!=owner:
357
+ raise PackError('Existing connection principal differs')
358
+ reuse=bool(old and old.get('input')==payload and old.get('agentWorkspaceId')==args.agent_workspace and old.get('packRevision')==revision)
359
+ proposed={'schemaVersion':1,'environment':args.environment,'contextKey':args.context_key,'identity':owner,
360
+ 'agentWorkspaceId':args.agent_workspace,'input':payload,'packRevision':revision}
361
+ if claude_plan is not None:
362
+ claude_context_targets(lib,args,proposed,claude_plan)
363
+ if local_pending:
364
+ if not reuse or any(old.get(k)!=v for k,v in proposed.items()):
365
+ raise PackError('Claude attachment recovery binding differs; no new connection or binding replacement')
366
+ qualify_claude_pending(lib,args,old,claude_plan,local_pending)
367
+ if reuse:
368
+ remote=client.action('app_topolo_agent.agent_packs.get',{'instanceId':instance},args.agent_workspace)['connection'];readback=remote
369
+ else:
370
+ prepared={'input':payload,'identity':owner,'revision':revision,'agentWorkspaceId':args.agent_workspace}
371
+ if getattr(client,'_pack_connect_scope',None) is not None:
372
+ raise PackError('A trusted connection dispatch scope is already active')
373
+ atomic_json(journal,prepared)
374
+ client._pack_connect_scope=scope
375
+ try:
376
+ qualify_connection_scope(scope)
377
+ result=client.action('app_topolo_agent.agent_packs.connect',payload,args.agent_workspace,write=True)
378
+ except ConnectionSourceRefused:
379
+ if not pending and read_json(journal)==prepared:journal.unlink()
380
+ raise
381
+ finally:client._pack_connect_scope=None
382
+ remote=result['connection'];readback=client.action('app_topolo_agent.agent_packs.get',{'instanceId':instance},args.agent_workspace)['connection']
155
383
  if remote!=readback or any(readback.get(k)!=v for k,v in payload.items()) or readback.get('organizationId')!=owner['organizationId'] or readback.get('workspaceId')!=args.agent_workspace:
156
384
  raise PackError('Pack connection read-back mismatch; preserve pending receipt')
157
385
  binding={'schemaVersion':1,'environment':args.environment,'contextKey':args.context_key,'identity':owner,
158
386
  'agentWorkspaceId':args.agent_workspace,'input':payload,'connection':readback,'packRevision':revision}
159
- atomic_json(path,binding);journal.unlink()
160
- atomic_json(path.parent.parent/'active.json',{'environment':args.environment,'contextKey':args.context_key})
387
+ if local_pending:
388
+ if binding!=old:
389
+ raise PackError('Claude attachment verified remote binding changed; no local replay')
390
+ else:
391
+ atomic_json(path,binding)
392
+ if journal.exists():journal.unlink()
393
+ atomic_json(path.parent.parent/'active.json',{'environment':args.environment,'contextKey':args.context_key})
161
394
  active=lib.active()
162
- if active.get('runtime'):attach_runtime(lib,active['runtime'])
163
- return {'state':'connected','binding':str(path),'instanceId':instance,'roles':len(roles),'environment':args.environment}
395
+ if claude_plan is not None:attachment=attach_claude(lib,args,binding,claude_plan)
396
+ else:
397
+ if active.get('runtime'):attach_runtime(lib,active['runtime'])
398
+ attachment=None
399
+ return {'state':'connected','binding':str(path),'instanceId':instance,'roles':len(roles),'environment':args.environment,'localAttachment':attachment}
164
400
 
165
401
  def remote_context(lib,args,client):
166
402
  _,binding=open_binding(lib,args,client)
@@ -266,10 +502,13 @@ def recover_connection(lib,args,client):
266
502
  """Read/reconcile receipts. Never replay a memory create with an unknown outcome."""
267
503
  path=binding_path(lib,args.name,args.environment,args.context_key)
268
504
  pending_path=path.with_suffix('.pending.json')
269
- if not path.exists() and pending_path.exists():
505
+ if pending_path.exists():
270
506
  pending=read_json(pending_path)
271
507
  if identity(client)!=pending['identity']:
272
508
  raise PackError('Pending connection principal/context mismatch')
509
+ older=read_json(path,{})
510
+ if older and (older.get('schemaVersion')!=1 or older.get('environment')!=args.environment or older.get('contextKey')!=args.context_key or older.get('identity')!=pending['identity'] or older.get('agentWorkspaceId')!=pending['agentWorkspaceId'] or older.get('input',{}).get('instanceId')!=pending['input']['instanceId']):
511
+ raise PackError('Existing connection custody differs from the pending original request')
273
512
  remote=client.action('app_topolo_agent.agent_packs.get',{'instanceId':pending['input']['instanceId']},pending['agentWorkspaceId'])['connection']
274
513
  if any(remote.get(k)!=v for k,v in pending['input'].items()) or remote.get('organizationId')!=pending['identity']['organizationId'] or remote.get('workspaceId')!=pending['agentWorkspaceId']:
275
514
  raise PackError('Pending connection read-back mismatch; retain receipt')
@@ -278,7 +517,7 @@ def recover_connection(lib,args,client):
278
517
  pending_path.unlink()
279
518
  atomic_json(path.parent.parent/'active.json',{'environment':args.environment,'contextKey':args.context_key})
280
519
  active=lib.active()
281
- if active.get('runtime'):attach_runtime(lib,active['runtime'])
520
+ if active.get('runtime') and getattr(args,'harness','codex')!='claude':attach_runtime(lib,active['runtime'])
282
521
  path,binding=open_binding(lib,args,client);reports=[]
283
522
  memory=path.with_suffix('.memory-pending.json')
284
523
  journals=([memory] if memory.exists() else [])+list(path.with_suffix('').glob('artifact-*.json'))
@@ -305,8 +544,9 @@ def recover_connection(lib,args,client):
305
544
  state='artifact-and-memory-verified';atomic_json(journal,{'state':state,'artifact':receipt['artifact'],'memoryId':matches[0]['id'],'recovered':True})
306
545
  else:state='unresolved-memory-outcome'
307
546
  reports.append({'receipt':str(journal),'state':state})
308
- return {'state':'reconciled' if not path.with_suffix('.pending.json').exists() and all(r['state'] in ['memory-verified','artifact-and-memory-verified'] for r in reports) else 'recovery-required',
309
- 'receipts':reports,'connectionPending':path.with_suffix('.pending.json').exists()}
547
+ return {'state':'reconciled' if not path.with_suffix('.pending.json').exists() and not path.with_suffix('.claude-context.pending.json').exists() and all(r['state'] in ['memory-verified','artifact-and-memory-verified'] for r in reports) else 'recovery-required',
548
+ 'receipts':reports,'connectionPending':path.with_suffix('.pending.json').exists(),
549
+ 'localContextRecoveryRequired':path.with_suffix('.claude-context.pending.json').exists(),'localContextReplayed':False}
310
550
 
311
551
  @contextlib.contextmanager
312
552
  def connection_lock(lib,args):
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@topolo/cli",
3
3
  "packageManager": "pnpm@10.17.1",
4
- "version": "0.13.3",
5
- "topoloPublishedVersion": "0.13.3",
4
+ "version": "0.13.4",
5
+ "topoloPublishedVersion": "0.13.4",
6
6
  "description": "Public command-line interface for Topolo customer organizations, developers, and agents.",
7
7
  "license": "UNLICENSED",
8
8
  "author": "Topolo",