@topolo/cli 0.13.19 → 0.13.21
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 +421 -1
- package/agent-packs/docs/selected-artifacts.md +47 -0
- package/agent-packs/documentation/cloud-task-state.md +56 -0
- package/agent-packs/domain-contexts/README.md +25 -0
- package/agent-packs/domain-contexts/cicd.json +8 -0
- package/agent-packs/gui/feed-adapter.js +4 -0
- package/agent-packs/gui/index.html +9 -0
- package/agent-packs/gui/studio.css +14 -0
- package/agent-packs/gui/studio.js +121 -0
- package/agent-packs/gui/studio_server.py +355 -0
- package/agent-packs/roles/cicd/CLAUDE.md +24 -0
- package/agent-packs/specializations/cicd--cicd.json +5 -0
- package/agent-packs/specializations/cicd--pipeline-engineering.json +5 -0
- package/agent-packs/specializations/cicd--promotion-deployment.json +5 -0
- package/agent-packs/specializations/cicd--queue-recovery.json +5 -0
- package/agent-packs/specializations/cicd--release-verification.json +5 -0
- package/agent-packs/specializations/cicd--runner-capacity.json +5 -0
- package/agent-packs/specializations/intakes--engineering-02.json +5 -0
- package/agent-packs/specializations/intakes--engineering-03.json +5 -0
- package/agent-packs/specializations/intakes--engineering.json +5 -0
- package/agent-packs/specializations/intakes--operator-01.json +5 -0
- package/agent-packs/specializations/intakes--operator-02.json +5 -0
- package/agent-packs/specializations/operator-skills--audio-and-narration-specialist.json +5 -0
- package/agent-packs/specializations/operator-skills--brand-and-copywriting-specialist.json +5 -0
- package/agent-packs/specializations/operator-skills--capability-research-specialist.json +5 -0
- package/agent-packs/specializations/operator-skills--data-analysis-and-visualization.json +5 -0
- package/agent-packs/specializations/operator-skills--financial-evidence-specialist.json +5 -0
- package/agent-packs/specializations/operator-skills--operating-template-specialist.json +5 -0
- package/agent-packs/specializations/operator-skills--operator-skill-library.json +5 -0
- package/agent-packs/specializations/operator-skills--operator-skills.json +5 -0
- package/agent-packs/specializations/operator-skills--operator-workflow-evaluation.json +5 -0
- package/agent-packs/specializations/operator-skills--privacy-and-agreement-review.json +5 -0
- package/agent-packs/specializations/operator-skills--storytelling-specialist.json +5 -0
- package/agent-packs/specializations/operator-skills--ux-and-accessibility-specialist.json +5 -0
- package/agent-packs/specializations/operator-skills--video-production-specialist.json +5 -0
- package/agent-packs/specializations/operator-skills--visual-design-specialist.json +5 -0
- package/agent-packs/specializations/operator-skills--workflow-integration-specialist.json +5 -0
- package/agent-packs/specializations/operators--access-and-privacy.json +5 -0
- package/agent-packs/specializations/operators--accounting-evidence-review.json +5 -0
- package/agent-packs/specializations/operators--agreement-operations.json +5 -0
- package/agent-packs/specializations/operators--books-operator.json +5 -0
- package/agent-packs/specializations/operators--brand-operator.json +5 -0
- package/agent-packs/specializations/operators--commerce-operations.json +5 -0
- package/agent-packs/specializations/operators--communications-operator.json +5 -0
- package/agent-packs/specializations/operators--content-and-assets-operator.json +5 -0
- package/agent-packs/specializations/operators--customer-operations.json +5 -0
- package/agent-packs/specializations/operators--design-operator.json +5 -0
- package/agent-packs/specializations/operators--design-production-02.json +5 -0
- package/agent-packs/specializations/operators--design-production-03.json +5 -0
- package/agent-packs/specializations/operators--design-production-04.json +5 -0
- package/agent-packs/specializations/operators--device-and-feed-operator.json +5 -0
- package/agent-packs/specializations/operators--director-operator.json +5 -0
- package/agent-packs/specializations/operators--experience-review.json +5 -0
- package/agent-packs/specializations/operators--growth-operations.json +5 -0
- package/agent-packs/specializations/operators--learn-operator.json +5 -0
- package/agent-packs/specializations/operators--localization-operator.json +5 -0
- package/agent-packs/specializations/operators--operators.json +5 -0
- package/agent-packs/specializations/operators--pay-operator.json +5 -0
- package/agent-packs/specializations/operators--research-and-intelligence.json +5 -0
- package/agent-packs/specializations/operators--roadmap-operator.json +5 -0
- package/agent-packs/specializations/operators--social-content-operator.json +5 -0
- package/agent-packs/specializations/operators--talent-operations.json +5 -0
- package/agent-packs/specializations/operators--web-operator.json +5 -0
- package/agent-packs/specializations/operators--workflow-operator.json +5 -0
- package/agent-packs/specializations/specialists--adversarial-qa.json +5 -0
- package/agent-packs/specializations/specialists--backlog-dispatch.json +5 -0
- package/agent-packs/specializations/specialists--build-01.json +5 -0
- package/agent-packs/specializations/specialists--build-02.json +5 -0
- package/agent-packs/specializations/specialists--build-03.json +5 -0
- package/agent-packs/specializations/specialists--developer-environment-steward.json +5 -0
- package/agent-packs/specializations/specialists--documentation-knowledge-steward.json +5 -0
- package/agent-packs/specializations/specialists--execution-readiness.json +5 -0
- package/agent-packs/specializations/specialists--incident-triage.json +5 -0
- package/agent-packs/specializations/specialists--platform-package-steward.json +5 -0
- package/agent-packs/specializations/specialists--product-direction-01.json +5 -0
- package/agent-packs/specializations/specialists--product-direction-02.json +5 -0
- package/agent-packs/specializations/specialists--release-recovery.json +5 -0
- package/agent-packs/specializations/specialists--release-watch.json +5 -0
- package/agent-packs/specializations/specialists--roadmap-planning-01.json +5 -0
- package/agent-packs/specializations/specialists--roadmap-planning-02.json +5 -0
- package/agent-packs/specializations/specialists--security-permissions-review.json +5 -0
- package/agent-packs/specializations/specialists--system-architecture-01.json +5 -0
- package/agent-packs/specializations/specialists--system-architecture-02.json +5 -0
- package/agent-packs/specializations/specialists--translation.json +5 -0
- package/agent-packs/specializations/specialists--ux-consistency-review.json +5 -0
- package/agent-packs/specializations/specialists--workflow-improvement.json +5 -0
- package/agent-packs/specializations/specialists--worktree-retirement.json +5 -0
- package/agent-packs/src/topolo_agent_pack/cicd.py +101 -0
- package/agent-packs/src/topolo_agent_pack/claude.py +22 -7
- package/agent-packs/src/topolo_agent_pack/claude_activation.py +157 -0
- package/agent-packs/src/topolo_agent_pack/claude_completion_recovery.py +216 -0
- package/agent-packs/src/topolo_agent_pack/claude_desktop.py +181 -0
- package/agent-packs/src/topolo_agent_pack/claude_dispatch.py +240 -0
- package/agent-packs/src/topolo_agent_pack/claude_prompt.py +74 -0
- package/agent-packs/src/topolo_agent_pack/claude_runner.py +207 -0
- package/agent-packs/src/topolo_agent_pack/claude_service.py +397 -0
- package/agent-packs/src/topolo_agent_pack/cli.py +181 -16
- package/agent-packs/src/topolo_agent_pack/closed_ledger_fence.py +118 -0
- package/agent-packs/src/topolo_agent_pack/cloud_attach.py +128 -0
- package/agent-packs/src/topolo_agent_pack/cloud_cutover.py +134 -0
- package/agent-packs/src/topolo_agent_pack/cloud_delta.py +162 -0
- package/agent-packs/src/topolo_agent_pack/cloud_migrate.py +142 -0
- package/agent-packs/src/topolo_agent_pack/cloud_recovery.py +42 -0
- package/agent-packs/src/topolo_agent_pack/cloud_work.py +126 -0
- package/agent-packs/src/topolo_agent_pack/coordinator_activity.py +27 -0
- package/agent-packs/src/topolo_agent_pack/coordinator_context.py +91 -0
- package/agent-packs/src/topolo_agent_pack/coordinator_refresh.py +116 -0
- package/agent-packs/src/topolo_agent_pack/desktop_acceptance.py +45 -0
- package/agent-packs/src/topolo_agent_pack/desktop_launcher.py +114 -0
- package/agent-packs/src/topolo_agent_pack/domain_context.py +38 -0
- package/agent-packs/src/topolo_agent_pack/execution.py +103 -6
- package/agent-packs/src/topolo_agent_pack/frontdoor.py +23 -0
- package/agent-packs/src/topolo_agent_pack/linked_task_context.py +80 -0
- package/agent-packs/src/topolo_agent_pack/live.py +285 -18
- package/agent-packs/src/topolo_agent_pack/native.py +160 -4
- package/agent-packs/src/topolo_agent_pack/review_context.py +33 -0
- package/agent-packs/src/topolo_agent_pack/role_catalog.py +137 -0
- package/agent-packs/src/topolo_agent_pack/role_context.py +63 -0
- package/agent-packs/src/topolo_agent_pack/role_refresh.py +50 -0
- package/agent-packs/src/topolo_agent_pack/topolo.py +7 -1
- package/agent-packs/src/topolo_agent_pack/work.py +322 -0
- package/agent-packs/src/topolo_agent_pack/work_adoption.py +122 -0
- package/agent-packs/src/topolo_agent_pack/work_artifacts.py +84 -0
- package/agent-packs/src/topolo_agent_pack/work_context.py +77 -0
- package/agent-packs/src/topolo_agent_pack/work_gui.py +51 -0
- package/agent-packs/src/topolo_agent_pack/work_native.py +414 -0
- package/agent-packs/src/topolo_agent_pack/work_wait.py +151 -0
- package/agent-packs/templates/topolo-coordinators/AGENTS.md +7 -0
- package/agent-packs/templates/topolo-coordinators/agents/intakes/cicd/AGENTS.md +6 -0
- package/agent-packs/templates/topolo-coordinators/agents/intakes/cicd/config.json +4 -0
- package/agent-packs/templates/topolo-coordinators/agents/intakes/engineering/AGENTS.md +3 -0
- package/agent-packs/templates/topolo-coordinators/agents/intakes/engineering/config.json +4 -0
- package/agent-packs/templates/topolo-coordinators/agents/intakes/operator-01/AGENTS.md +3 -0
- package/agent-packs/templates/topolo-coordinators/agents/intakes/operator-01/config.json +4 -0
- package/agent-packs/templates/topolo-coordinators/pack.json +56 -0
- package/dist/chunk-NYDIAYVY.js +144 -0
- package/dist/{claude-pack-backend-YEVPAOID.js → chunk-UY5LEU5C.js} +1659 -21
- package/dist/claude-desktop-profile-JCQKSY3K.js +13 -0
- package/dist/claude-pack-activation-Q25MCEPU.js +506 -0
- package/dist/claude-pack-backend-HUTKGIDS.js +90 -0
- package/dist/index.js +89 -10
- package/package.json +4 -4
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
"""Owning Claude cloud-dispatch connection, using the existing GUI protocol."""
|
|
2
|
+
import asyncio
|
|
3
|
+
import fcntl
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import pathlib
|
|
8
|
+
import secrets
|
|
9
|
+
import shlex
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
|
|
14
|
+
from .claude_dispatch import ClaudeBroker
|
|
15
|
+
from .live import request, socket_path
|
|
16
|
+
from .storage import Library, PackError, atomic_json, canonical, read_json
|
|
17
|
+
from .work import require
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def verify_retired_binding(lib, previous, connection):
|
|
21
|
+
"""A new app process never inherits unresolved native worker custody."""
|
|
22
|
+
old = previous.get('nativeConnection', {})
|
|
23
|
+
require(isinstance(old, dict) and set(old) == {'pid', 'runtime', 'identity', 'socket', 'secretPath'}
|
|
24
|
+
and type(old['pid']) is int and old['pid'] > 1 and old['pid'] == previous.get('desktopPid')
|
|
25
|
+
and old['pid'] != connection['pid'] and old['runtime'] == connection['runtime']
|
|
26
|
+
and old['runtime'] == str(lib.root / 'claude/task-runtime'),
|
|
27
|
+
'Native Desktop connection changed; previous owner identity is unverified')
|
|
28
|
+
secret = pathlib.Path(old['secretPath'])
|
|
29
|
+
require(secret.is_relative_to(lib.root / 'claude/journals') and secret.resolve() == secret
|
|
30
|
+
and not any(parent.is_symlink() for parent in secret.parents) and not secret.is_symlink()
|
|
31
|
+
and secret.is_file() and secret.stat().st_uid == os.getuid()
|
|
32
|
+
and not secret.stat().st_mode & 0o077,
|
|
33
|
+
'Previous native connection secret is not privately owned')
|
|
34
|
+
try:
|
|
35
|
+
os.kill(old['pid'], 0)
|
|
36
|
+
except ProcessLookupError:
|
|
37
|
+
pass
|
|
38
|
+
except OSError as exc:
|
|
39
|
+
raise PackError('Previous Desktop process absence is unverified') from exc
|
|
40
|
+
else:
|
|
41
|
+
raise PackError('Previous Desktop process is alive; preserve its worker custody')
|
|
42
|
+
from .cloud_work import open_store
|
|
43
|
+
from .work_artifacts import read_checkpoint
|
|
44
|
+
store = open_store(lib.root)
|
|
45
|
+
require(getattr(store, 'cloud', False), 'Restart binding requires activated cloud task authority')
|
|
46
|
+
snapshot = store.snapshot()
|
|
47
|
+
tasks = {row['id']: row for row in snapshot['tasks']}
|
|
48
|
+
for worker in snapshot['workers']:
|
|
49
|
+
task = tasks.get(worker['taskId'])
|
|
50
|
+
cwd = worker.get('nativePlan', {}).get('cwd', '')
|
|
51
|
+
if isinstance(cwd, str) and cwd.startswith(old['runtime'] + os.sep):
|
|
52
|
+
require(os.path.normpath(cwd) == cwd, 'Previous native worker runtime path is ambiguous')
|
|
53
|
+
owned = isinstance(cwd, str) and pathlib.Path(cwd).is_absolute() and pathlib.Path(cwd).is_relative_to(old['runtime']) and cwd != old['runtime']
|
|
54
|
+
if not owned:
|
|
55
|
+
if task and task.get('ownerInstallationId') == store.binding['installationId']:
|
|
56
|
+
require((worker['state'] == 'archived' and worker.get('nativeArchived') is True)
|
|
57
|
+
or (worker['state'] == 'failed' and not worker.get('turnId')
|
|
58
|
+
and (not worker.get('nativeId') or worker.get('nativeArchived') is True)),
|
|
59
|
+
'Previous historical worker remains active or UNKNOWN; preserve its custody')
|
|
60
|
+
continue
|
|
61
|
+
if worker['state']=='failed' and not worker.get('turnId') and (not worker.get('nativeId') or worker.get('nativeArchived') is True):
|
|
62
|
+
continue
|
|
63
|
+
require(worker['state'] == 'archived' and worker.get('nativeArchived') is True
|
|
64
|
+
and worker.get('checkpoint') and worker.get('turnId'),
|
|
65
|
+
'Previous native worker remains active or UNKNOWN; checkpoint adoption is required')
|
|
66
|
+
payload = read_checkpoint(store.root, worker['checkpoint'], worker['taskId'], worker['id'])
|
|
67
|
+
receipt = payload['nativeReceipt']
|
|
68
|
+
require(payload.get('resolution', {}).get('harness') == 'claude'
|
|
69
|
+
and payload['phase'] == worker['phase'] and receipt['threadId'] == worker['nativeId']
|
|
70
|
+
and receipt['turnId'] == worker['turnId'] and receipt['status'] in {'completed', 'failed', 'cancelled'},
|
|
71
|
+
'Previous native worker terminal checkpoint differs')
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def verified_predecessor_hook(lib, previous, rows, existing):
|
|
75
|
+
"""Migrate only the exact bound, content-addressed private hook runtime."""
|
|
76
|
+
require(previous.get('format') == 'topolo-claude-frontdoors-v1'
|
|
77
|
+
and previous.get('roles') == [
|
|
78
|
+
{'sessionId': row['sessionId'], 'cwd': row['cwd'],
|
|
79
|
+
'channel': {'engineering': 'engineering', 'operator-01': 'operations', 'cicd': 'cicd'}[row['key'].split('/')[-1]]}
|
|
80
|
+
for row in rows], 'Existing coordinator hook has another owner; preserve it')
|
|
81
|
+
require(isinstance(existing, list) and len(existing) == 1 and set(existing[0]) == {'hooks'}
|
|
82
|
+
and isinstance(existing[0]['hooks'], list) and len(existing[0]['hooks']) == 1,
|
|
83
|
+
'Existing coordinator hook has another owner; preserve it')
|
|
84
|
+
hook = existing[0]['hooks'][0]
|
|
85
|
+
require(isinstance(hook, dict) and set(hook) == {'type', 'command', 'timeout'}
|
|
86
|
+
and hook['type'] == 'command' and hook['timeout'] == 240,
|
|
87
|
+
'Existing coordinator hook has another owner; preserve it')
|
|
88
|
+
try:
|
|
89
|
+
args = shlex.split(hook['command'])
|
|
90
|
+
except (ValueError, TypeError) as exc:
|
|
91
|
+
raise PackError('Predecessor hook command is malformed') from exc
|
|
92
|
+
require(len(args) == 11 and args[0] == sys.executable
|
|
93
|
+
and args[2:] == ['--store', str(lib.root), 'work', 'claude-hook', '--harness', 'claude', '||', 'exit', '2'],
|
|
94
|
+
'Predecessor hook command differs from the owned adapter')
|
|
95
|
+
adapter = pathlib.Path(args[1])
|
|
96
|
+
runtime = adapter.parent
|
|
97
|
+
require(adapter.name == 'topolo-agent-pack' and runtime.parent == lib.root / 'claude/hook-runtimes'
|
|
98
|
+
and len(runtime.name) == 64 and all(c in '0123456789abcdef' for c in runtime.name),
|
|
99
|
+
'Predecessor hook runtime is outside the owned content-addressed namespace')
|
|
100
|
+
hashes = {}
|
|
101
|
+
for path in [runtime, *sorted(runtime.rglob('*'))]:
|
|
102
|
+
stat = path.lstat()
|
|
103
|
+
require(not path.is_symlink() and path.resolve() == path and stat.st_uid == os.getuid()
|
|
104
|
+
and not stat.st_mode & (0o077 if path == runtime else 0o022) and (path.is_dir() or path.is_file()),
|
|
105
|
+
'Predecessor hook runtime is not privately owned')
|
|
106
|
+
# The materializer uses localeCompare: underscore sorts before the extension dot.
|
|
107
|
+
files = [adapter, *sorted((runtime / 'src/topolo_agent_pack').rglob('*.py'),
|
|
108
|
+
key=lambda p: str(p.relative_to(runtime)).replace('_', '\x00').replace('.', '\x01'))]
|
|
109
|
+
caches = {p for p in runtime.rglob('*.pyc') if p.parent.name == '__pycache__'
|
|
110
|
+
and p.parent.parent == runtime / 'src/topolo_agent_pack'
|
|
111
|
+
and any(p.name.startswith(source.stem + '.cpython-') for source in files[1:])}
|
|
112
|
+
require(adapter.is_file() and len(files) > 1
|
|
113
|
+
and {p for p in runtime.rglob('*') if p.is_file()} == set(files) | caches,
|
|
114
|
+
'Predecessor hook runtime source closure differs')
|
|
115
|
+
for path in files:
|
|
116
|
+
hashes[str(path.relative_to(runtime))] = hashlib.sha256(path.read_bytes()).hexdigest()
|
|
117
|
+
digest = hashlib.sha256(json.dumps(hashes, separators=(',', ':'), ensure_ascii=False).encode()).hexdigest()
|
|
118
|
+
require(digest == runtime.name, 'Predecessor hook runtime content digest differs')
|
|
119
|
+
require(hook['command'] == shlex.join(args[:-3]) + ' || exit 2',
|
|
120
|
+
'Predecessor hook command encoding differs')
|
|
121
|
+
return existing
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def install(root, value):
|
|
125
|
+
lib = Library(root)
|
|
126
|
+
require(isinstance(value, dict) and set(value) == {'desktopPid', 'roles', 'nativeConnection'}
|
|
127
|
+
and type(value['desktopPid']) is int and value['desktopPid'] > 1,
|
|
128
|
+
'Verified native Desktop process and coordinator sessions required')
|
|
129
|
+
config = read_json(lib.root / 'work/config.json')
|
|
130
|
+
require(config.get('format') == 'topolo-task-dispatch-v2' and config.get('requireCloudAuthority') is True,
|
|
131
|
+
'Install source-independent cloud task authority before Claude dispatch')
|
|
132
|
+
channels = {'engineering': 'engineering', 'operator-01': 'operations', 'cicd': 'cicd'}
|
|
133
|
+
rows = value['roles']
|
|
134
|
+
require(isinstance(rows, list) and len(rows) == 3 and
|
|
135
|
+
{r.get('key') for r in rows} == {config['pack'] + '/intakes/' + k for k in channels},
|
|
136
|
+
'Exactly the three installed coordinator roles are required')
|
|
137
|
+
require(len({r.get('sessionId') for r in rows}) == 3, 'Coordinator SDK session identities must differ')
|
|
138
|
+
adapter = pathlib.Path(__file__).resolve().parents[2] / 'topolo-agent-pack'
|
|
139
|
+
require(adapter.is_file(), 'Installed bundled pack entrypoint is unavailable')
|
|
140
|
+
command = shlex.join([sys.executable, str(adapter), '--store', str(lib.root),
|
|
141
|
+
'work', 'claude-hook', '--harness', 'claude']) + ' || exit 2'
|
|
142
|
+
previous = read_json(lib.root / 'work/claude-frontdoors.json', {})
|
|
143
|
+
bindings = []
|
|
144
|
+
for row in rows:
|
|
145
|
+
require(set(row) == {'key', 'sessionId', 'cwd'} and isinstance(row['sessionId'], str)
|
|
146
|
+
and row['sessionId'] and pathlib.Path(row['cwd']).is_absolute(), 'Invalid native coordinator binding')
|
|
147
|
+
cwd = pathlib.Path(row['cwd'])
|
|
148
|
+
require(cwd.is_relative_to(lib.root / 'claude/workspaces') and cwd.is_dir()
|
|
149
|
+
and not cwd.is_symlink() and not any(p.is_symlink() for p in cwd.parents),
|
|
150
|
+
'Front-door hooks require an owned regular Claude workspace')
|
|
151
|
+
directory = cwd / '.claude'
|
|
152
|
+
require(not directory.is_symlink(), 'Claude settings directory cannot be a symlink')
|
|
153
|
+
path = directory / 'settings.json'
|
|
154
|
+
require(not path.is_symlink(), 'Claude settings cannot be a symlink')
|
|
155
|
+
settings = read_json(path, {})
|
|
156
|
+
hooks = settings.get('hooks', {})
|
|
157
|
+
for event in ['UserPromptSubmit', 'PreToolUse']:
|
|
158
|
+
existing = hooks.get(event, [])
|
|
159
|
+
expected = [{'hooks': [{'type': 'command', 'command': command, 'timeout': 240}]}]
|
|
160
|
+
if existing and existing != expected:
|
|
161
|
+
verified_predecessor_hook(lib, previous, rows, existing)
|
|
162
|
+
bindings.append({'sessionId': row['sessionId'], 'cwd': str(cwd),
|
|
163
|
+
'channel': channels[row['key'].split('/')[-1]]})
|
|
164
|
+
from .claude_desktop import call
|
|
165
|
+
connection = value['nativeConnection']
|
|
166
|
+
require(isinstance(connection, dict) and set(connection) == {'pid', 'runtime', 'identity', 'socket', 'secretPath'}
|
|
167
|
+
and connection['pid'] == value['desktopPid']
|
|
168
|
+
and connection['runtime'] == str(lib.root / 'claude/task-runtime'), 'Exact native Desktop connection required')
|
|
169
|
+
native = call(connection, {'kind': 'status'})
|
|
170
|
+
require(native.get('harness') == 'claude-desktop' and native.get('pid') == value['desktopPid']
|
|
171
|
+
and native.get('identity') == connection['identity'] and native.get('models'),
|
|
172
|
+
'Signed-in Desktop authentication and model catalog are unavailable')
|
|
173
|
+
if previous and previous.get('nativeConnection') != connection:
|
|
174
|
+
verify_retired_binding(lib, previous, connection)
|
|
175
|
+
process = subprocess.run(['/bin/ps', '-ww', '-p', str(value['desktopPid']), '-o', 'comm='],
|
|
176
|
+
capture_output=True, text=True, timeout=5)
|
|
177
|
+
executable = process.stdout.strip()
|
|
178
|
+
require(process.returncode == 0 and pathlib.Path(executable).is_absolute()
|
|
179
|
+
and pathlib.Path(executable).name == 'Claude', 'Verified Claude Desktop executable is unavailable')
|
|
180
|
+
# Refresh selected cloud role/domain context before exposing any front door.
|
|
181
|
+
from .role_context import materialize
|
|
182
|
+
for row in rows:
|
|
183
|
+
materialize(lib.root / 'work', row['key'], row['cwd'], harness='claude')
|
|
184
|
+
# Check every destination before changing any hook. The binding is published
|
|
185
|
+
# first, so a newly observed hook can only dispatch through the selected owner.
|
|
186
|
+
atomic_json(lib.root / 'work/claude-frontdoors.json',
|
|
187
|
+
{'format': 'topolo-claude-frontdoors-v1', 'desktopPid': value['desktopPid'],
|
|
188
|
+
'nativeConnection': connection,
|
|
189
|
+
'desktopExecutable': executable, 'roles': bindings})
|
|
190
|
+
for row in rows:
|
|
191
|
+
path = pathlib.Path(row['cwd']) / '.claude/settings.json'
|
|
192
|
+
settings = read_json(path, {})
|
|
193
|
+
hooks = settings.setdefault('hooks', {})
|
|
194
|
+
for event in ['UserPromptSubmit', 'PreToolUse']:
|
|
195
|
+
hooks[event] = [{'hooks': [{'type': 'command', 'command': command, 'timeout': 240}]}]
|
|
196
|
+
atomic_json(path, settings)
|
|
197
|
+
require(read_json(path) == settings, 'Coordinator hook readback differs')
|
|
198
|
+
return {'state': 'claude-frontdoors-prepared', 'coordinators': 3,
|
|
199
|
+
'historyRead': False, 'cloudDispatchVerified': False, 'nativePinsChanged': False}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class Service:
|
|
203
|
+
def __init__(self, lib):
|
|
204
|
+
self.lib = lib
|
|
205
|
+
self.broker = ClaudeBroker(lib)
|
|
206
|
+
self.token = secrets.token_hex(32)
|
|
207
|
+
self.path = socket_path(lib)
|
|
208
|
+
self.server = None
|
|
209
|
+
self.desktop_pid = None
|
|
210
|
+
self.clients = set()
|
|
211
|
+
|
|
212
|
+
async def control(self, reader, writer):
|
|
213
|
+
self.clients.add(writer)
|
|
214
|
+
try:
|
|
215
|
+
value = json.loads(await asyncio.wait_for(reader.readline(), 5))
|
|
216
|
+
require(secrets.compare_digest(value.get('token', ''), self.token), 'Dispatch connection authentication failed')
|
|
217
|
+
op, params = value.get('operation'), value.get('params') or {}
|
|
218
|
+
if op == 'status' and not params:
|
|
219
|
+
result = {'harness': 'claude', 'cloudDispatchReady': True, 'runtime': self.broker.owner.runtime,
|
|
220
|
+
'desktopPid': self.desktop_pid,
|
|
221
|
+
'activeTurns': [k for k, s in self.broker.sessions.items() if s.assigned and not s.closed],
|
|
222
|
+
'workStartupError': None}
|
|
223
|
+
elif op == 'work-submit': result = await self.broker.submit(params)
|
|
224
|
+
elif op == 'work-respond': result = await self.broker.respond(params)
|
|
225
|
+
elif op == 'work-re-review': result = await self.broker.re_review(params)
|
|
226
|
+
elif op == 'work-list' and not params: result = await self.broker.snapshot()
|
|
227
|
+
elif op == 'work-read' and set(params) == {'taskId'}: result = await self.broker.read(params['taskId'])
|
|
228
|
+
elif op == 'work-retained': result = await self.broker.state_call('retained_page', params)
|
|
229
|
+
elif op == 'work-watch' and not params:
|
|
230
|
+
await self.broker.watch(reader, writer)
|
|
231
|
+
return
|
|
232
|
+
else: raise PackError('Unsupported Claude dispatch operation')
|
|
233
|
+
writer.write(canonical(result) + b'\n')
|
|
234
|
+
except Exception as exc:
|
|
235
|
+
writer.write(canonical({'error': str(exc)}) + b'\n')
|
|
236
|
+
finally:
|
|
237
|
+
try:
|
|
238
|
+
await writer.drain()
|
|
239
|
+
except (ConnectionError, OSError):
|
|
240
|
+
pass
|
|
241
|
+
writer.close()
|
|
242
|
+
self.clients.discard(writer)
|
|
243
|
+
|
|
244
|
+
async def run(self):
|
|
245
|
+
with (self.lib.root / 'work/claude-service-owner.lock').open('a') as lock:
|
|
246
|
+
try: fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
247
|
+
except BlockingIOError as exc: raise PackError('Claude dispatcher already has an owning process') from exc
|
|
248
|
+
await self.run_owned()
|
|
249
|
+
|
|
250
|
+
async def run_owned(self):
|
|
251
|
+
require(not (self.lib.root / 'work/claude-shutdown-uncertain.json').exists(),
|
|
252
|
+
'Prior Claude shutdown needs cloud checkpoint reconciliation before startup')
|
|
253
|
+
await self.broker.initialize()
|
|
254
|
+
binding = read_json(self.lib.root / 'work/claude-frontdoors.json')
|
|
255
|
+
pid = binding['desktopPid']
|
|
256
|
+
self.desktop_pid = pid
|
|
257
|
+
os.kill(pid, 0)
|
|
258
|
+
record = self.lib.root / 'live-bridge.json'
|
|
259
|
+
old = read_json(record, {})
|
|
260
|
+
if old:
|
|
261
|
+
try:
|
|
262
|
+
request(self.lib, 'status', {})
|
|
263
|
+
except (PackError, OSError):
|
|
264
|
+
pass
|
|
265
|
+
else:
|
|
266
|
+
raise PackError('An owning connection is already live; no competing dispatcher was started')
|
|
267
|
+
if self.path.exists():
|
|
268
|
+
require(not self.path.is_symlink() and self.path.stat().st_uid == os.getuid(), 'Unowned dispatch socket')
|
|
269
|
+
self.path.unlink()
|
|
270
|
+
self.server = await asyncio.start_unix_server(self.control, path=self.path, limit=1024 * 1024)
|
|
271
|
+
self.path.chmod(0o600)
|
|
272
|
+
atomic_json(record, {'socket': str(self.path), 'token': self.token, 'harness': 'claude', 'pid': os.getpid()})
|
|
273
|
+
self.broker.launch(self.broker.pump())
|
|
274
|
+
try:
|
|
275
|
+
while True:
|
|
276
|
+
await asyncio.sleep(2)
|
|
277
|
+
try: os.kill(pid, 0)
|
|
278
|
+
except ProcessLookupError: break
|
|
279
|
+
finally:
|
|
280
|
+
self.server.close()
|
|
281
|
+
# Python 3.13+ waits for accepted clients too. Watch streams must
|
|
282
|
+
# close before waiting, otherwise a dead Desktop retains custody
|
|
283
|
+
# forever while an idle GUI is connected.
|
|
284
|
+
for writer in tuple(self.clients): writer.close()
|
|
285
|
+
await self.server.wait_closed()
|
|
286
|
+
for job in list(self.broker.jobs): job.cancel()
|
|
287
|
+
await asyncio.gather(*self.broker.jobs, return_exceptions=True)
|
|
288
|
+
closed = await asyncio.gather(*(self.shutdown_session(wid, session)
|
|
289
|
+
for wid, session in self.broker.sessions.items()), return_exceptions=True)
|
|
290
|
+
if read_json(record, {}).get('token') == self.token:
|
|
291
|
+
record.unlink()
|
|
292
|
+
self.path.unlink(missing_ok=True)
|
|
293
|
+
if any(isinstance(result, BaseException) for result in closed):
|
|
294
|
+
atomic_json(self.lib.root / 'work/claude-shutdown-uncertain.json',
|
|
295
|
+
{'state': 'cloud-shutdown-checkpoint-unconfirmed',
|
|
296
|
+
'workerIds': list(self.broker.sessions), 'replayAuthorized': False})
|
|
297
|
+
raise PackError('Claude processes closed; cloud shutdown checkpoint is unconfirmed. Reconcile without replay.')
|
|
298
|
+
|
|
299
|
+
async def shutdown_session(self, wid, session):
|
|
300
|
+
try:
|
|
301
|
+
if session.assigned and not session.closed:
|
|
302
|
+
worker = await self.broker.state_call('worker', wid)
|
|
303
|
+
if worker['state'] != 'archived':
|
|
304
|
+
await self.broker.state_call('worker_update', wid,
|
|
305
|
+
{'state': 'unknown', 'error': 'Owning Desktop service ended before verified closure.'})
|
|
306
|
+
task = await self.broker.state_call('get', worker['taskId'])
|
|
307
|
+
await self.broker.state_call('update', task['id'], task['revision'],
|
|
308
|
+
{'state': 'unknown', 'summary': 'Owning Desktop service ended; reconcile the existing worker before resuming.'},
|
|
309
|
+
'recovery-required')
|
|
310
|
+
finally:
|
|
311
|
+
await session.close()
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def ensure_running(lib):
|
|
315
|
+
expected_pid = read_json(lib.root / 'work/claude-frontdoors.json').get('desktopPid')
|
|
316
|
+
require(type(expected_pid) is int and expected_pid > 1, 'Current native Desktop owner binding is required')
|
|
317
|
+
try:
|
|
318
|
+
status = request(lib, 'status', {})
|
|
319
|
+
except (PackError, OSError):
|
|
320
|
+
status = None
|
|
321
|
+
if status is not None:
|
|
322
|
+
require(status.get('harness') == 'claude' and status.get('cloudDispatchReady') is True
|
|
323
|
+
and status.get('desktopPid') == expected_pid,
|
|
324
|
+
'Another native owner holds this installation; do not start a competing dispatcher')
|
|
325
|
+
return
|
|
326
|
+
lock_path = lib.root / 'work/claude-service-start.lock'
|
|
327
|
+
with lock_path.open('a') as lock:
|
|
328
|
+
fcntl.flock(lock, fcntl.LOCK_EX)
|
|
329
|
+
try:
|
|
330
|
+
status = request(lib, 'status', {})
|
|
331
|
+
except (PackError, OSError):
|
|
332
|
+
status = None
|
|
333
|
+
if status is not None:
|
|
334
|
+
require(status.get('harness') == 'claude' and status.get('cloudDispatchReady') is True
|
|
335
|
+
and status.get('desktopPid') == expected_pid,
|
|
336
|
+
'Another native owner holds this installation')
|
|
337
|
+
return
|
|
338
|
+
# Verify cloud activation before spawning a persistent process. There is
|
|
339
|
+
# no SQLite fallback and an unchanged refusal does not start a retry loop.
|
|
340
|
+
from .cloud_work import open_store
|
|
341
|
+
store = open_store(lib.root)
|
|
342
|
+
require(getattr(store, 'cloud', False), 'Claude requires activated cloud task authority')
|
|
343
|
+
store.load()
|
|
344
|
+
env = dict(os.environ)
|
|
345
|
+
env['PYTHONPATH'] = str(pathlib.Path(__file__).resolve().parent.parent)
|
|
346
|
+
adapter = pathlib.Path(__file__).resolve().parents[2] / 'topolo-agent-pack'
|
|
347
|
+
require(adapter.is_file(), 'Installed bundled service entrypoint is unavailable')
|
|
348
|
+
log = (lib.root / 'work/claude-service.log').open('ab')
|
|
349
|
+
try:
|
|
350
|
+
child = subprocess.Popen([sys.executable, str(adapter), '--store', str(lib.root),
|
|
351
|
+
'work', 'serve-claude', '--harness', 'claude'],
|
|
352
|
+
stdin=subprocess.DEVNULL, stdout=log, stderr=log, env=env, start_new_session=True)
|
|
353
|
+
finally:
|
|
354
|
+
log.close()
|
|
355
|
+
deadline = time.monotonic() + 120
|
|
356
|
+
while child.poll() is None and time.monotonic() < deadline:
|
|
357
|
+
try:
|
|
358
|
+
status = request(lib, 'status', {})
|
|
359
|
+
require(status.get('harness') == 'claude' and status.get('cloudDispatchReady') is True
|
|
360
|
+
and status.get('desktopPid') == expected_pid,
|
|
361
|
+
'Unexpected owning dispatch connection')
|
|
362
|
+
return
|
|
363
|
+
except (PackError, OSError):
|
|
364
|
+
time.sleep(.1)
|
|
365
|
+
if child.poll() is None:
|
|
366
|
+
child.terminate()
|
|
367
|
+
child.wait(timeout=10)
|
|
368
|
+
raise PackError('Claude dispatcher did not activate; inspect its owning startup log. No request was submitted.')
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def refresh_desktop_owner(lib):
|
|
372
|
+
"""Resolve this native hook's Desktop ancestor after an ordinary app restart.
|
|
373
|
+
|
|
374
|
+
Uses the executable observed at setup, not a PID cache, version allowlist,
|
|
375
|
+
browser automation, debugger or local conversation history.
|
|
376
|
+
"""
|
|
377
|
+
path = lib.root / 'work/claude-frontdoors.json'
|
|
378
|
+
binding = read_json(path)
|
|
379
|
+
expected = binding.get('desktopExecutable')
|
|
380
|
+
require(isinstance(expected, str) and pathlib.Path(expected).is_absolute(),
|
|
381
|
+
'Verified Desktop executable binding is missing')
|
|
382
|
+
pid = os.getppid()
|
|
383
|
+
seen = set()
|
|
384
|
+
for _ in range(16):
|
|
385
|
+
require(pid > 1 and pid not in seen, 'This hook has no bound Claude Desktop ancestor')
|
|
386
|
+
seen.add(pid)
|
|
387
|
+
observed = subprocess.run(['/bin/ps', '-ww', '-p', str(pid), '-o', 'ppid=', '-o', 'comm='],
|
|
388
|
+
capture_output=True, text=True, timeout=5)
|
|
389
|
+
fields = observed.stdout.strip().split(None, 1)
|
|
390
|
+
require(observed.returncode == 0 and len(fields) == 2, 'Native Desktop ancestry is unavailable')
|
|
391
|
+
if fields[1] == expected:
|
|
392
|
+
require(binding['desktopPid']==pid and binding.get('nativeConnection',{}).get('pid')==pid,
|
|
393
|
+
'Restarted Desktop requires the qualified pack launcher; prior worker custody is preserved')
|
|
394
|
+
return pid
|
|
395
|
+
require(fields[0].isdigit(), 'Native parent process identity is invalid')
|
|
396
|
+
pid = int(fields[0])
|
|
397
|
+
raise PackError('Native Desktop ancestry exceeds its bounded setup contract')
|