@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.
- package/agent-packs/README.md +154 -0
- package/agent-packs/src/topolo_agent_pack/claude.py +265 -0
- package/agent-packs/src/topolo_agent_pack/cli.py +29 -2
- package/agent-packs/src/topolo_agent_pack/execution.py +154 -0
- package/agent-packs/src/topolo_agent_pack/live.py +49 -8
- package/agent-packs/src/topolo_agent_pack/policy.py +92 -0
- package/dist/claude-pack-backend-IDQQX3DG.js +555 -0
- package/dist/index.js +256 -82
- package/package.json +5 -5
package/agent-packs/README.md
CHANGED
|
@@ -339,3 +339,157 @@ unresolved for explicit reconciliation. An uploaded but unregistered artifact
|
|
|
339
339
|
remains stored in Bytes and is reported separately. An uncertain connection can
|
|
340
340
|
be retried with the identical immutable connect request. Switching does not
|
|
341
341
|
reset a remote binding or erase Agent memory or Bytes files.
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
## Claude Desktop preview
|
|
345
|
+
|
|
346
|
+
Claude Code Desktop 2.1.289 is qualified for native session import through
|
|
347
|
+
`--desktop --resume`. Each fresh pack role has its own named sidebar session,
|
|
348
|
+
CLAUDE.md, portable context files and persistent Claude session ID. No old Codex
|
|
349
|
+
conversation is copied and import submits no new task.
|
|
350
|
+
|
|
351
|
+
```sh
|
|
352
|
+
topolo-agent-pack add topolo-clean --harness claude --import-only
|
|
353
|
+
topolo-agent-pack add topolo-clean --harness claude --group intakes --import-only
|
|
354
|
+
topolo-agent-pack add topolo-clean --harness claude --agent topolo-clean/intakes/engineering --import-only
|
|
355
|
+
topolo-agent-pack status --harness claude
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
The public CLI source also dispatches `topolo agent-packs add ... --harness claude`;
|
|
359
|
+
this source change has not been published to the registry.
|
|
360
|
+
|
|
361
|
+
The CLI import handoff does not control outgoing membership, groups or pins.
|
|
362
|
+
Switching through that handoff refuses before changes. The experimental native
|
|
363
|
+
connection below provides a separate source implementation for those operations;
|
|
364
|
+
it still requires independent live and customer installation proof.
|
|
365
|
+
The adapter never edits Claude's live metadata, account state or permissions.
|
|
366
|
+
`recover` reads actual Desktop adoption records and reports incomplete imports;
|
|
367
|
+
repeating `add` resumes pending adoption with the same IDs. Workspaces and
|
|
368
|
+
transcripts live under the selected pack library and Claude config respectively.
|
|
369
|
+
Default Claude auto-cleanup still applies to transcripts; this preview is not a
|
|
370
|
+
portable backup of accumulated Claude history. Existing Codex switching is
|
|
371
|
+
unchanged. Role-identity inference, hosted Agent/Bytes shared memory and clean
|
|
372
|
+
machine installation require separate acceptance.
|
|
373
|
+
|
|
374
|
+
## Agent execution policy
|
|
375
|
+
|
|
376
|
+
`topolo agent-packs policy topolo-clean` renders the portable model, reasoning,
|
|
377
|
+
approval and sidebar intent for every role. Use `--group`, `--agent`,
|
|
378
|
+
`--harness codex|claude` and `--output /absolute/path/review.json` to select or
|
|
379
|
+
save a review copy. The command never changes existing sessions or permissions.
|
|
380
|
+
An agent can declare `executionProfile` as `intake`, `analysis`, `review`,
|
|
381
|
+
`build` or `operator`; otherwise the adapter resolves its role default.
|
|
382
|
+
|
|
383
|
+
The initial Claude policy uses Opus 5.5 for reasoning roles and Sonnet 5.5 for
|
|
384
|
+
execution. Haiku 5.5 is not selected for the qualified 2.1.289 host, because it
|
|
385
|
+
requires 2.1.293 or later. Codex uses GPT-6 Astra for reasoning roles and
|
|
386
|
+
GPT-6.1 Sol for execution. These are desired settings, not proof of account
|
|
387
|
+
availability or live session selection.
|
|
388
|
+
|
|
389
|
+
Codex sandboxing and Claude permission modes have separate mappings. Claude
|
|
390
|
+
Manual does not enforce a read-only filesystem sandbox. The policy records
|
|
391
|
+
that extra confinement requirement instead of treating the two controls as
|
|
392
|
+
equivalent. Platform mutations still require the existing scoped authorization.
|
|
393
|
+
|
|
394
|
+
Role context and relevant memory should be loaded on demand, with bounded tool
|
|
395
|
+
results and one evidence-linked handoff. An idle pack should generate no model
|
|
396
|
+
turns or background polling. No token savings are claimed without usage evidence.
|
|
397
|
+
|
|
398
|
+
Claude session adoption is currently verified independently from complete pack
|
|
399
|
+
activation. The external native import command does not provide live group,
|
|
400
|
+
pin, model or outgoing-roster controls. `activationReady` and
|
|
401
|
+
`modelAndPermissionSettingsVerified` remain false until a qualified backend
|
|
402
|
+
transport applies and reads back those settings. UI automation is not part of
|
|
403
|
+
the adapter. Import status is deliberately separate from native activation proof.
|
|
404
|
+
### Experimental Claude live configuration
|
|
405
|
+
|
|
406
|
+
The native import is not pack activation. `configure` can preflight and apply
|
|
407
|
+
an already adopted roster through the running Claude main process. This is an
|
|
408
|
+
internal compatibility adapter qualified for **Claude Desktop 2.31226.1** and
|
|
409
|
+
the exact native module hashes in `src/lib/claude-pack-backend.ts`; it refuses
|
|
410
|
+
other builds. It invokes native session-manager APIs and acknowledged sidebar
|
|
411
|
+
commands. It does not automate the UI or edit cached desktop records.
|
|
412
|
+
|
|
413
|
+
```sh
|
|
414
|
+
topolo agent-packs policy topolo-clean --harness claude --output /absolute/review.json
|
|
415
|
+
topolo agent-packs configure topolo-clean --harness claude \
|
|
416
|
+
--policy /absolute/review.json --pid <Claude-main-PID> \
|
|
417
|
+
--endpoint ws://127.0.0.1:<port>/<inspector-UUID>
|
|
418
|
+
# After reviewing the preflight, repeat the same command with --apply.
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
The endpoint must be enabled separately with the machine owner's explicit
|
|
422
|
+
authorization: a main-process debugger allows local code execution. The
|
|
423
|
+
adapter does not enable debugging, signal/restart Claude, patch its app bundle,
|
|
424
|
+
or leave a background service. Close a temporarily enabled debugger after the
|
|
425
|
+
operation. This experimental connection is not a released customer setup path.
|
|
426
|
+
The inspected 2.31226.1 build disables Electron's `nodeCliInspect` fuse, so
|
|
427
|
+
startup flags and `SIGUSR1` do not enable its inspector. Its native Developer
|
|
428
|
+
Mode setup requires a one-time restart, followed by the Developer menu's
|
|
429
|
+
`Enable Main Process Debugger` setting. Any such setup is separately authorized;
|
|
430
|
+
do not patch fuses or assume a restart flag opens the endpoint.
|
|
431
|
+
|
|
432
|
+
All selected sessions must be idle, have matching titles/workspaces, and use
|
|
433
|
+
models from the native account catalog. Settings use Manual or Accept edits;
|
|
434
|
+
no bypass permission is accepted. An fsynced journal records the baseline and
|
|
435
|
+
each attempted native effect before dispatch. On a disconnect or failed
|
|
436
|
+
acknowledgement, use the explicit read-only native recovery command below before
|
|
437
|
+
retrying; do not assume failure means no effects. Application independently
|
|
438
|
+
reads back groups, pins, models, effort and permission modes and hashes role
|
|
439
|
+
charters/transcripts before and after. It does not claim an inference test,
|
|
440
|
+
read-only filesystem confinement or hosted memory verification. Those remain
|
|
441
|
+
separate acceptance boundaries.
|
|
442
|
+
|
|
443
|
+
### Experimental native Claude switching and recovery
|
|
444
|
+
|
|
445
|
+
First configure and verify the initial adopted pack. Prepare/import the target
|
|
446
|
+
pack independently and render its complete reviewed policy. Then preflight:
|
|
447
|
+
|
|
448
|
+
```sh
|
|
449
|
+
topolo agent-packs switch personal --harness claude \
|
|
450
|
+
--policy /absolute/personal-policy.json --pid <Claude-main-PID> \
|
|
451
|
+
--endpoint ws://127.0.0.1:<port>/<inspector-UUID>
|
|
452
|
+
# Repeat with --apply after reviewing the result.
|
|
453
|
+
topolo agent-packs recover personal --harness claude \
|
|
454
|
+
--policy /absolute/personal-policy.json --pid <Claude-main-PID> \
|
|
455
|
+
--endpoint ws://127.0.0.1:<port>/<inspector-UUID>
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
Switching archives only the explicit previously verified outgoing roster and
|
|
459
|
+
unarchives the existing target identities. It does not delete sessions, clean
|
|
460
|
+
worktrees, create model turns or touch unrelated sessions. Live work, remote or
|
|
461
|
+
linked workspaces and nonempty archive cascades refuse before effects. Role
|
|
462
|
+
files and transcripts are hashed inside the native operation and independently
|
|
463
|
+
by the caller. Python imports and native commands share the same library lock;
|
|
464
|
+
the native manager also fences competing operations across libraries.
|
|
465
|
+
|
|
466
|
+
An interrupted native operation blocks further imports or native operations.
|
|
467
|
+
Recovery reads the exact sealed operation plan and native state; it never
|
|
468
|
+
replays effects. A completely verified target can finalize an interrupted
|
|
469
|
+
commit. If the journal proves no effect was attempted, recovery releases that
|
|
470
|
+
operation without changing the previous active composition. Partial effects,
|
|
471
|
+
changed context and a still-running operation remain recovery-required; they
|
|
472
|
+
need supported owner reconciliation. No expiry or PID observation clears a
|
|
473
|
+
pending operation. `status --harness claude` distinguishes adoption readback,
|
|
474
|
+
the last verified active roster and pending recovery; cached proof is not
|
|
475
|
+
reported as current native settings. Use explicit native recovery above rather
|
|
476
|
+
than retrying an uncertain apply.
|
|
477
|
+
|
|
478
|
+
### Managed Codex role execution
|
|
479
|
+
|
|
480
|
+
New managed compositions carry a closed per-role execution policy. The owned
|
|
481
|
+
proxy qualifies the installed executable's exported protocol offline and reads
|
|
482
|
+
its account model catalog. On native `thread/resume` it supplies the selected
|
|
483
|
+
role's model, effort, approval reviewer and local sandbox without starting a
|
|
484
|
+
turn. An effective-settings mismatch blocks subsequent model work. Conflicting
|
|
485
|
+
frontend overrides are reported; the adapter never silently substitutes models
|
|
486
|
+
or expands permissions. Update reviewed policy explicitly to change it.
|
|
487
|
+
Read-only roles have no command network access; workspace writers are confined
|
|
488
|
+
to their role workspace with temporary-directory roots excluded. Platform tool
|
|
489
|
+
authorization remains separate from local command sandboxing.
|
|
490
|
+
|
|
491
|
+
Managed bridge status reports requested policy and which resumes have current
|
|
492
|
+
native readback. It does not claim an inference test or independent app-tool
|
|
493
|
+
approval readback. Existing runtimes without this policy report not-configured
|
|
494
|
+
until explicitly rebuilt. Source fixtures do not establish live desktop or
|
|
495
|
+
three-environment shared-memory acceptance.
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""Versioned Claude Code Desktop session import. Never rewrites live app records.
|
|
2
|
+
|
|
3
|
+
Fresh role files and CLI transcripts are owned by this adapter. Desktop adopts
|
|
4
|
+
them through its native resume handoff. There is no external archive API in
|
|
5
|
+
that handoff; full roster replacement is therefore refused, not simulated.
|
|
6
|
+
"""
|
|
7
|
+
import argparse
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import pathlib
|
|
12
|
+
import pty
|
|
13
|
+
import select as io_select
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import time
|
|
18
|
+
import uuid
|
|
19
|
+
|
|
20
|
+
from .storage import Library, PackError, atomic_json, canonical, now, safe_id, select
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def find_binary():
|
|
24
|
+
root = pathlib.Path.home() / 'Library/Application Support/Claude/claude-code'
|
|
25
|
+
candidates = list(root.glob('*/*/claude.app/Contents/MacOS/claude'))
|
|
26
|
+
if candidates:
|
|
27
|
+
return str(max(candidates, key=lambda p: tuple(int(x) for x in p.relative_to(root).parts[0].split('.'))))
|
|
28
|
+
return shutil.which('claude')
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def handoff(binary, directory, session_id):
|
|
32
|
+
"""Native --desktop requires a TTY. No prompt, model call or shell is used."""
|
|
33
|
+
if not binary:
|
|
34
|
+
raise PackError('Claude Code 2.1.285+ is required; install Claude Desktop or Claude Code.')
|
|
35
|
+
version = subprocess.run([binary, '--version'], capture_output=True, text=True, timeout=10)
|
|
36
|
+
if version.returncode or not version.stdout.startswith('2.1.289 '):
|
|
37
|
+
raise PackError('This native import adapter is qualified for Claude Code 2.1.289; selected binary: ' + version.stdout.strip())
|
|
38
|
+
master, slave = pty.openpty()
|
|
39
|
+
process = None
|
|
40
|
+
output = bytearray()
|
|
41
|
+
try:
|
|
42
|
+
process = subprocess.Popen([binary, '--desktop', '--resume', session_id],
|
|
43
|
+
cwd=directory, stdin=slave, stdout=slave, stderr=slave)
|
|
44
|
+
os.close(slave)
|
|
45
|
+
slave = None
|
|
46
|
+
deadline = time.monotonic() + 20
|
|
47
|
+
while process.poll() is None:
|
|
48
|
+
if time.monotonic() >= deadline:
|
|
49
|
+
raise PackError('Claude Desktop handoff timed out; run recover to reconcile imports.')
|
|
50
|
+
if io_select.select([master], [], [], .1)[0]:
|
|
51
|
+
try:
|
|
52
|
+
chunk = os.read(master, 65536)
|
|
53
|
+
except OSError:
|
|
54
|
+
break
|
|
55
|
+
output.extend(chunk)
|
|
56
|
+
if len(output) > 65536:
|
|
57
|
+
raise PackError('Claude Desktop handoff exceeded its output limit.')
|
|
58
|
+
process.wait(timeout=2)
|
|
59
|
+
if process.returncode or ('Opening session ' + session_id) not in output.decode(errors='replace'):
|
|
60
|
+
raise PackError('Claude refused the native Desktop handoff: ' + output.decode(errors='replace')[-2000:])
|
|
61
|
+
finally:
|
|
62
|
+
if process is not None and process.poll() is None:
|
|
63
|
+
process.terminate()
|
|
64
|
+
process.wait(timeout=5)
|
|
65
|
+
if slave is not None:
|
|
66
|
+
os.close(slave)
|
|
67
|
+
os.close(master)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def project_directory(config, directory):
|
|
71
|
+
# The native CLI's current path-key codec. A versioned compatibility boundary.
|
|
72
|
+
import re
|
|
73
|
+
key = re.sub(r'[^a-zA-Z0-9]', '-', str(directory))
|
|
74
|
+
if len(key) > 200:
|
|
75
|
+
raise PackError('Claude project path exceeds the qualified path-key boundary (200 characters).')
|
|
76
|
+
return config / 'projects' / key
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def write_new(path, data):
|
|
80
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
81
|
+
if path.exists() or path.is_symlink():
|
|
82
|
+
if path.is_symlink() or not path.is_file() or path.read_bytes() != data:
|
|
83
|
+
raise PackError('Refusing to overwrite existing context or transcript: ' + str(path))
|
|
84
|
+
return
|
|
85
|
+
with path.open('xb') as stream:
|
|
86
|
+
stream.write(data)
|
|
87
|
+
stream.flush()
|
|
88
|
+
os.fsync(stream.fileno())
|
|
89
|
+
path.chmod(0o600)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def materialize(lib, name, selected, config):
|
|
93
|
+
manifest, revision = lib.load(name)
|
|
94
|
+
if manifest.get('startingContext', {}).get('kind') != 'fresh':
|
|
95
|
+
raise PackError('Claude import requires a fresh pack with reviewed role files; old Codex history is not translated.')
|
|
96
|
+
records = []
|
|
97
|
+
root = lib.root / 'claude'
|
|
98
|
+
for agent in selected:
|
|
99
|
+
safe_id(agent['set'])
|
|
100
|
+
safe_id(agent['id'])
|
|
101
|
+
key = name + '/' + agent['set'] + '/' + agent['id']
|
|
102
|
+
identity = root / 'identities' / (hashlib.sha256(key.encode()).hexdigest() + '.json')
|
|
103
|
+
if identity.exists():
|
|
104
|
+
record = json.loads(identity.read_text())
|
|
105
|
+
if record['sourceRevision'] != revision:
|
|
106
|
+
raise PackError('Existing Claude agent uses a different revision; build a new named pack to preserve its context.')
|
|
107
|
+
if record['importState'] != 'preparing':
|
|
108
|
+
records.append(record)
|
|
109
|
+
continue
|
|
110
|
+
else:
|
|
111
|
+
session_id = str(uuid.uuid4())
|
|
112
|
+
workspace = root / 'workspaces' / name / agent['set'] / agent['id']
|
|
113
|
+
record = {'key': key, 'pack': name, 'set': agent['set'], 'agent': agent['id'],
|
|
114
|
+
'title': agent['title'], 'pinnedRequested': agent.get('pinned', False),
|
|
115
|
+
'sourceRevision': revision, 'sourceCodexThreadId': agent['threadId'],
|
|
116
|
+
'cliSessionId': session_id, 'desktopSessionId': 'local_' + session_id,
|
|
117
|
+
'workspace': str(workspace),
|
|
118
|
+
'transcript': str(project_directory(config, workspace) / (session_id + '.jsonl')),
|
|
119
|
+
'importState': 'preparing', 'createdAt': now(), 'bootstrapUuid': str(uuid.uuid4())}
|
|
120
|
+
# Save allocation before any workspace/transcript write. Retrying a
|
|
121
|
+
# interrupted preparation uses the same IDs and compares old bytes.
|
|
122
|
+
atomic_json(identity, record)
|
|
123
|
+
original = pathlib.Path(manifest['threads'][agent['threadId']]['cwd'])
|
|
124
|
+
workspace = pathlib.Path(record['workspace'])
|
|
125
|
+
if workspace.is_symlink():
|
|
126
|
+
raise PackError('Claude workspace must not be a symlink')
|
|
127
|
+
workspace.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
128
|
+
resources = [r for r in manifest['resources'] if pathlib.Path(r['original']).is_relative_to(original)]
|
|
129
|
+
instructions = next((r for r in resources if pathlib.Path(r['original']).relative_to(original).as_posix() == 'AGENTS.md'), None)
|
|
130
|
+
if instructions is None:
|
|
131
|
+
raise PackError('Missing reviewed role instructions for ' + key)
|
|
132
|
+
for resource in resources:
|
|
133
|
+
relative = pathlib.Path(resource['original']).relative_to(original)
|
|
134
|
+
if '..' in relative.parts or any(p.is_symlink() for p in (workspace / relative).parents if p != lib.root.parent):
|
|
135
|
+
raise PackError('Unsafe context path: ' + str(relative))
|
|
136
|
+
# Never copy runtime state, prior tasks, scripts or credentials into a role.
|
|
137
|
+
if relative.suffix != '.md' or relative.as_posix() == 'context/TEAM.md':
|
|
138
|
+
continue
|
|
139
|
+
if relative.as_posix() == 'AGENTS.md':
|
|
140
|
+
relative = pathlib.Path('CLAUDE.md')
|
|
141
|
+
write_new(workspace / relative, lib.object(resource['content']).read_bytes())
|
|
142
|
+
session_id = record['cliSessionId']
|
|
143
|
+
transcript = pathlib.Path(record['transcript'])
|
|
144
|
+
note = (agent['title'] + '. You are this Topolo Agent Pack role in Claude Desktop. '
|
|
145
|
+
'Read CLAUDE.md and relevant context/ files before work. '
|
|
146
|
+
'This is starting context only; no assignment, claim or prior conversation is inherited. '
|
|
147
|
+
'Await a new human request. Coordination requires human authorization.')
|
|
148
|
+
rows = [{'parentUuid': None, 'isSidechain': False, 'userType': 'external',
|
|
149
|
+
'cwd': str(workspace), 'sessionId': session_id, 'version': '2.1.289',
|
|
150
|
+
'type': 'user', 'message': {'role': 'user', 'content': note},
|
|
151
|
+
'uuid': record['bootstrapUuid'], 'timestamp': record['createdAt']},
|
|
152
|
+
{'type': 'custom-title', 'sessionId': session_id, 'customTitle': agent['title']}]
|
|
153
|
+
write_new(transcript, b''.join(canonical(row) + b'\n' for row in rows))
|
|
154
|
+
record['importState'] = 'prepared'
|
|
155
|
+
atomic_json(identity, record)
|
|
156
|
+
records.append(record)
|
|
157
|
+
team = ('# Claude pack roster\n\nThese are Claude Desktop session identities. '
|
|
158
|
+
'No task, claim, messaging authorization or hosted connection is granted by this roster.\n\n' +
|
|
159
|
+
'\n'.join('- ' + a['key'] + ' | ' + a['title'] + ' | ' + a['desktopSessionId'] for a in records) + '\n')
|
|
160
|
+
for record in records:
|
|
161
|
+
context = pathlib.Path(record['workspace']) / 'context'
|
|
162
|
+
write_new(context / ('TEAM-' + hashlib.sha256(team.encode()).hexdigest()[:16] + '.md'), team.encode())
|
|
163
|
+
pointer = context / 'TEAM.md'
|
|
164
|
+
if not pointer.exists():
|
|
165
|
+
write_new(pointer, b'# Claude team identities\n\nRead TEAM-*.md for immutable Claude roster snapshots. Use topolo-agent-pack status --harness claude for current membership. Only human-authorized coordination is permitted.\n')
|
|
166
|
+
return records
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def readback(records, app_data):
|
|
170
|
+
results = []
|
|
171
|
+
for record in records:
|
|
172
|
+
files = list((app_data / 'claude-code-sessions').glob('*/*/' + record['desktopSessionId'] + '.json'))
|
|
173
|
+
if len(files) != 1:
|
|
174
|
+
results.append({'key': record['key'], 'verified': False, 'reason': 'unique Desktop adoption record missing'})
|
|
175
|
+
continue
|
|
176
|
+
state = json.loads(files[0].read_text())
|
|
177
|
+
ok = (state.get('cliSessionId') == record['cliSessionId'] and
|
|
178
|
+
state.get('title') == record['title'] and
|
|
179
|
+
state.get('cwd') == record['workspace'] and not state.get('isArchived'))
|
|
180
|
+
results.append({'key': record['key'], 'desktopSessionId': record['desktopSessionId'],
|
|
181
|
+
'verified': ok, 'completedTurns': state.get('completedTurns') or 0,
|
|
182
|
+
'record': str(files[0])})
|
|
183
|
+
return results
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def parser():
|
|
187
|
+
p = argparse.ArgumentParser(description='Claude Code Desktop pack sessions (native sidebar import; full switch is not yet supported).')
|
|
188
|
+
p.add_argument('--store', default=os.environ.get('CODEX_PACK_HOME', str(pathlib.Path.home() / 'AgentPacks')))
|
|
189
|
+
p.add_argument('--claude-config', default=str(pathlib.Path.home() / '.claude'))
|
|
190
|
+
p.add_argument('--claude-data', default=str(pathlib.Path.home() / 'Library/Application Support/Claude'))
|
|
191
|
+
sub = p.add_subparsers(dest='command', required=True)
|
|
192
|
+
for action in ['add', 'switch', 'replace']:
|
|
193
|
+
q = sub.add_parser(action)
|
|
194
|
+
q.add_argument('source')
|
|
195
|
+
q.add_argument('--set', '--group', dest='sets', action='append')
|
|
196
|
+
q.add_argument('--agent', dest='agents', action='append')
|
|
197
|
+
q.add_argument('--catalog', action='store_true')
|
|
198
|
+
q.add_argument('--no-open', action='store_true')
|
|
199
|
+
q.add_argument('--import-only', action='store_true', help='Explicit incomplete native session import; does not activate pack settings')
|
|
200
|
+
q.add_argument('--claude-bin', default=find_binary())
|
|
201
|
+
for action in ['status', 'doctor', 'recover', 'list']:
|
|
202
|
+
sub.add_parser(action)
|
|
203
|
+
return p
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def main(argv):
|
|
207
|
+
args = parser().parse_args(argv)
|
|
208
|
+
try:
|
|
209
|
+
lib = Library(args.store)
|
|
210
|
+
app_data = pathlib.Path(args.claude_data).expanduser().resolve()
|
|
211
|
+
state_path = lib.root / 'claude/state.json'
|
|
212
|
+
with lib.lock():
|
|
213
|
+
state = json.loads(state_path.read_text()) if state_path.exists() else {'schemaVersion': 1, 'harness': 'claude', 'agents': []}
|
|
214
|
+
if args.command in {'status', 'doctor', 'recover', 'list'}:
|
|
215
|
+
proof = readback(state['agents'], app_data)
|
|
216
|
+
active_path = lib.root / 'claude/active.json'
|
|
217
|
+
pending_path = lib.root / 'claude/native-operation.json'
|
|
218
|
+
result = {**state, 'adoptionProof': proof, 'verifiedAgents': sum(p['verified'] for p in proof),
|
|
219
|
+
'fullPackSwitchSupported': False, 'sidebarGroupsAndPinsVerified': False,
|
|
220
|
+
'nativeRecordsRewritten': False, 'modelIdentityResponseVerified': False,
|
|
221
|
+
'activationReady': False, 'modelAndPermissionSettingsVerified': False,
|
|
222
|
+
'lastVerifiedActiveRoster': json.loads(active_path.read_text()) if active_path.exists() else None,
|
|
223
|
+
'nativeRecoveryRequired': pending_path.exists(),
|
|
224
|
+
'nativeRecoveryReplayed': False,
|
|
225
|
+
'verificationBoundary': 'read-only adoption records; use the explicit native connection for current settings or recovery'}
|
|
226
|
+
else:
|
|
227
|
+
if (lib.root / 'claude/native-operation.json').exists():
|
|
228
|
+
raise PackError('An unresolved native Claude operation blocks imports and preparation. Use topolo agent-packs recover with the explicit native connection; no effects are replayed.')
|
|
229
|
+
manifest, revision = lib.load(args.source)
|
|
230
|
+
chosen = select(manifest, args.sets, args.agents)
|
|
231
|
+
keys = {args.source + '/' + a['set'] + '/' + a['id'] for a in chosen}
|
|
232
|
+
outgoing = {a['key'] for a in state['agents']} - keys
|
|
233
|
+
if args.command in {'switch', 'replace'} and outgoing:
|
|
234
|
+
raise PackError('Claude full-pack replacement is not qualified: native --desktop --resume cannot archive the outgoing roster. No changes were made. Use add for sidebar import.')
|
|
235
|
+
if not args.no_open and not args.import_only:
|
|
236
|
+
raise PackError('Claude pack activation requires a qualified live backend for groups, pins, model and permission settings. No changes were made. --no-open prepares context; --import-only explicitly imports sessions without completing pack activation.')
|
|
237
|
+
records = materialize(lib, args.source, chosen, pathlib.Path(args.claude_config).expanduser().resolve())
|
|
238
|
+
combined = {a['key']: a for a in state['agents']}
|
|
239
|
+
combined.update({a['key']: a for a in records})
|
|
240
|
+
state = {**state, 'agents': list(combined.values()), 'updatedAt': now()}
|
|
241
|
+
# Durable recovery plan before asking Desktop to adopt a session.
|
|
242
|
+
atomic_json(state_path, state)
|
|
243
|
+
if not args.no_open:
|
|
244
|
+
for record in records:
|
|
245
|
+
if not readback([record], app_data)[0]['verified']:
|
|
246
|
+
handoff(args.claude_bin, record['workspace'], record['cliSessionId'])
|
|
247
|
+
deadline = time.monotonic() + 15
|
|
248
|
+
while not readback([record], app_data)[0]['verified']:
|
|
249
|
+
if time.monotonic() > deadline:
|
|
250
|
+
raise PackError('Desktop adoption not verified for ' + record['key'] + '; run recover.')
|
|
251
|
+
time.sleep(.1)
|
|
252
|
+
record['importState'] = 'desktop-adopted'
|
|
253
|
+
atomic_json(state_path, state)
|
|
254
|
+
proof = readback(records, app_data)
|
|
255
|
+
result = {'harness': 'claude', 'state': 'prepared' if args.no_open else 'desktop-adopted',
|
|
256
|
+
'selectedAgents': len(records), 'verifiedAgents': sum(p['verified'] for p in proof),
|
|
257
|
+
'adoptionProof': proof, 'fullPackSwitchSupported': False,
|
|
258
|
+
'previousCodexHistoryCopied': False, 'nativeRecordsRewritten': False,
|
|
259
|
+
'sidebarGroupsAndPinsVerified': False, 'activationReady': False,
|
|
260
|
+
'modelAndPermissionSettingsVerified': False}
|
|
261
|
+
print(json.dumps(result, indent=2))
|
|
262
|
+
return 0
|
|
263
|
+
except (PackError, OSError, ValueError, KeyError, subprocess.SubprocessError) as exc:
|
|
264
|
+
print('topolo-agent-pack (Claude): ' + str(exc), file=sys.stderr)
|
|
265
|
+
return 2
|
|
@@ -15,7 +15,7 @@ import time
|
|
|
15
15
|
import uuid
|
|
16
16
|
|
|
17
17
|
from . import __version__
|
|
18
|
-
from . import native, live, fresh, blueprint
|
|
18
|
+
from . import native, live, fresh, blueprint, execution
|
|
19
19
|
from .storage import Library, PackError, atomic_json, canonical, component_key, now, read_json, references, safe_id, select, sync_tree
|
|
20
20
|
|
|
21
21
|
def emit(value):
|
|
@@ -234,6 +234,7 @@ def apply_plan(lib,plan,args):
|
|
|
234
234
|
if local_config.exists():
|
|
235
235
|
shutil.copyfile(local_config,runtime/'home'/'config.toml');(runtime/'home'/'config.toml').chmod(0o600)
|
|
236
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)
|
|
237
238
|
journal['phase']='staged';atomic_json(jpath,journal)
|
|
238
239
|
if old_runtime and old_runtime!=runtime and (old_runtime/'home/auth.json').exists():
|
|
239
240
|
shutil.copyfile(old_runtime/'home/auth.json',runtime/'home/auth.json');(runtime/'home/auth.json').chmod(0o600)
|
|
@@ -313,6 +314,8 @@ def parser():
|
|
|
313
314
|
if action=='replace': q.add_argument('--target')
|
|
314
315
|
if action=='switch': q.add_argument('--fresh',action='store_true',help='Use the captured source instead of resuming a previously saved workbench')
|
|
315
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')
|
|
316
319
|
for command in ['connect','remote-context','remember','artifact','artifact-get','remote-recover']:
|
|
317
320
|
q=sub.add_parser(command,help='Use public Topolo Agent continuity and Bytes artifact contracts')
|
|
318
321
|
q.add_argument('name');q.add_argument('--environment',choices=['development','staging','production'],required=True)
|
|
@@ -335,6 +338,22 @@ def parser():
|
|
|
335
338
|
return p
|
|
336
339
|
|
|
337
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
|
|
338
357
|
args=parser().parse_args(argv)
|
|
339
358
|
try:
|
|
340
359
|
lib=Library(args.store)
|
|
@@ -344,7 +363,15 @@ def main(argv=None):
|
|
|
344
363
|
emit(result)
|
|
345
364
|
return 0
|
|
346
365
|
with lib.lock():
|
|
347
|
-
if args.command=='
|
|
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':
|
|
348
375
|
safe_id(args.name)
|
|
349
376
|
active=lib.active();template=None
|
|
350
377
|
if args.source_home: home=pathlib.Path(args.source_home)
|
|
@@ -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}
|