@topolo/cli 0.13.17 → 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.
Files changed (148) hide show
  1. package/README.md +18 -4
  2. package/agent-packs/README.md +469 -1
  3. package/agent-packs/coverage_changed.py +1 -1
  4. package/agent-packs/docs/selected-artifacts.md +47 -0
  5. package/agent-packs/documentation/cloud-task-state.md +56 -0
  6. package/agent-packs/domain-contexts/README.md +25 -0
  7. package/agent-packs/domain-contexts/cicd.json +8 -0
  8. package/agent-packs/gui/feed-adapter.js +4 -0
  9. package/agent-packs/gui/index.html +9 -0
  10. package/agent-packs/gui/studio.css +14 -0
  11. package/agent-packs/gui/studio.js +121 -0
  12. package/agent-packs/gui/studio_server.py +355 -0
  13. package/agent-packs/roles/cicd/CLAUDE.md +24 -0
  14. package/agent-packs/specializations/cicd--cicd.json +5 -0
  15. package/agent-packs/specializations/cicd--pipeline-engineering.json +5 -0
  16. package/agent-packs/specializations/cicd--promotion-deployment.json +5 -0
  17. package/agent-packs/specializations/cicd--queue-recovery.json +5 -0
  18. package/agent-packs/specializations/cicd--release-verification.json +5 -0
  19. package/agent-packs/specializations/cicd--runner-capacity.json +5 -0
  20. package/agent-packs/specializations/intakes--engineering-02.json +5 -0
  21. package/agent-packs/specializations/intakes--engineering-03.json +5 -0
  22. package/agent-packs/specializations/intakes--engineering.json +5 -0
  23. package/agent-packs/specializations/intakes--operator-01.json +5 -0
  24. package/agent-packs/specializations/intakes--operator-02.json +5 -0
  25. package/agent-packs/specializations/operator-skills--audio-and-narration-specialist.json +5 -0
  26. package/agent-packs/specializations/operator-skills--brand-and-copywriting-specialist.json +5 -0
  27. package/agent-packs/specializations/operator-skills--capability-research-specialist.json +5 -0
  28. package/agent-packs/specializations/operator-skills--data-analysis-and-visualization.json +5 -0
  29. package/agent-packs/specializations/operator-skills--financial-evidence-specialist.json +5 -0
  30. package/agent-packs/specializations/operator-skills--operating-template-specialist.json +5 -0
  31. package/agent-packs/specializations/operator-skills--operator-skill-library.json +5 -0
  32. package/agent-packs/specializations/operator-skills--operator-skills.json +5 -0
  33. package/agent-packs/specializations/operator-skills--operator-workflow-evaluation.json +5 -0
  34. package/agent-packs/specializations/operator-skills--privacy-and-agreement-review.json +5 -0
  35. package/agent-packs/specializations/operator-skills--storytelling-specialist.json +5 -0
  36. package/agent-packs/specializations/operator-skills--ux-and-accessibility-specialist.json +5 -0
  37. package/agent-packs/specializations/operator-skills--video-production-specialist.json +5 -0
  38. package/agent-packs/specializations/operator-skills--visual-design-specialist.json +5 -0
  39. package/agent-packs/specializations/operator-skills--workflow-integration-specialist.json +5 -0
  40. package/agent-packs/specializations/operators--access-and-privacy.json +5 -0
  41. package/agent-packs/specializations/operators--accounting-evidence-review.json +5 -0
  42. package/agent-packs/specializations/operators--agreement-operations.json +5 -0
  43. package/agent-packs/specializations/operators--books-operator.json +5 -0
  44. package/agent-packs/specializations/operators--brand-operator.json +5 -0
  45. package/agent-packs/specializations/operators--commerce-operations.json +5 -0
  46. package/agent-packs/specializations/operators--communications-operator.json +5 -0
  47. package/agent-packs/specializations/operators--content-and-assets-operator.json +5 -0
  48. package/agent-packs/specializations/operators--customer-operations.json +5 -0
  49. package/agent-packs/specializations/operators--design-operator.json +5 -0
  50. package/agent-packs/specializations/operators--design-production-02.json +5 -0
  51. package/agent-packs/specializations/operators--design-production-03.json +5 -0
  52. package/agent-packs/specializations/operators--design-production-04.json +5 -0
  53. package/agent-packs/specializations/operators--device-and-feed-operator.json +5 -0
  54. package/agent-packs/specializations/operators--director-operator.json +5 -0
  55. package/agent-packs/specializations/operators--experience-review.json +5 -0
  56. package/agent-packs/specializations/operators--growth-operations.json +5 -0
  57. package/agent-packs/specializations/operators--learn-operator.json +5 -0
  58. package/agent-packs/specializations/operators--localization-operator.json +5 -0
  59. package/agent-packs/specializations/operators--operators.json +5 -0
  60. package/agent-packs/specializations/operators--pay-operator.json +5 -0
  61. package/agent-packs/specializations/operators--research-and-intelligence.json +5 -0
  62. package/agent-packs/specializations/operators--roadmap-operator.json +5 -0
  63. package/agent-packs/specializations/operators--social-content-operator.json +5 -0
  64. package/agent-packs/specializations/operators--talent-operations.json +5 -0
  65. package/agent-packs/specializations/operators--web-operator.json +5 -0
  66. package/agent-packs/specializations/operators--workflow-operator.json +5 -0
  67. package/agent-packs/specializations/specialists--adversarial-qa.json +5 -0
  68. package/agent-packs/specializations/specialists--backlog-dispatch.json +5 -0
  69. package/agent-packs/specializations/specialists--build-01.json +5 -0
  70. package/agent-packs/specializations/specialists--build-02.json +5 -0
  71. package/agent-packs/specializations/specialists--build-03.json +5 -0
  72. package/agent-packs/specializations/specialists--developer-environment-steward.json +5 -0
  73. package/agent-packs/specializations/specialists--documentation-knowledge-steward.json +5 -0
  74. package/agent-packs/specializations/specialists--execution-readiness.json +5 -0
  75. package/agent-packs/specializations/specialists--incident-triage.json +5 -0
  76. package/agent-packs/specializations/specialists--platform-package-steward.json +5 -0
  77. package/agent-packs/specializations/specialists--product-direction-01.json +5 -0
  78. package/agent-packs/specializations/specialists--product-direction-02.json +5 -0
  79. package/agent-packs/specializations/specialists--release-recovery.json +5 -0
  80. package/agent-packs/specializations/specialists--release-watch.json +5 -0
  81. package/agent-packs/specializations/specialists--roadmap-planning-01.json +5 -0
  82. package/agent-packs/specializations/specialists--roadmap-planning-02.json +5 -0
  83. package/agent-packs/specializations/specialists--security-permissions-review.json +5 -0
  84. package/agent-packs/specializations/specialists--system-architecture-01.json +5 -0
  85. package/agent-packs/specializations/specialists--system-architecture-02.json +5 -0
  86. package/agent-packs/specializations/specialists--translation.json +5 -0
  87. package/agent-packs/specializations/specialists--ux-consistency-review.json +5 -0
  88. package/agent-packs/specializations/specialists--workflow-improvement.json +5 -0
  89. package/agent-packs/specializations/specialists--worktree-retirement.json +5 -0
  90. package/agent-packs/src/topolo_agent_pack/cicd.py +101 -0
  91. package/agent-packs/src/topolo_agent_pack/claude.py +22 -7
  92. package/agent-packs/src/topolo_agent_pack/claude_activation.py +157 -0
  93. package/agent-packs/src/topolo_agent_pack/claude_completion_recovery.py +216 -0
  94. package/agent-packs/src/topolo_agent_pack/claude_desktop.py +181 -0
  95. package/agent-packs/src/topolo_agent_pack/claude_dispatch.py +240 -0
  96. package/agent-packs/src/topolo_agent_pack/claude_prompt.py +74 -0
  97. package/agent-packs/src/topolo_agent_pack/claude_runner.py +207 -0
  98. package/agent-packs/src/topolo_agent_pack/claude_service.py +397 -0
  99. package/agent-packs/src/topolo_agent_pack/cli.py +188 -18
  100. package/agent-packs/src/topolo_agent_pack/closed_ledger_fence.py +118 -0
  101. package/agent-packs/src/topolo_agent_pack/cloud_attach.py +128 -0
  102. package/agent-packs/src/topolo_agent_pack/cloud_cutover.py +134 -0
  103. package/agent-packs/src/topolo_agent_pack/cloud_delta.py +162 -0
  104. package/agent-packs/src/topolo_agent_pack/cloud_migrate.py +142 -0
  105. package/agent-packs/src/topolo_agent_pack/cloud_recovery.py +42 -0
  106. package/agent-packs/src/topolo_agent_pack/cloud_work.py +126 -0
  107. package/agent-packs/src/topolo_agent_pack/coordinator_activity.py +27 -0
  108. package/agent-packs/src/topolo_agent_pack/coordinator_context.py +91 -0
  109. package/agent-packs/src/topolo_agent_pack/coordinator_refresh.py +116 -0
  110. package/agent-packs/src/topolo_agent_pack/desktop_acceptance.py +45 -0
  111. package/agent-packs/src/topolo_agent_pack/desktop_launcher.py +114 -0
  112. package/agent-packs/src/topolo_agent_pack/domain_context.py +38 -0
  113. package/agent-packs/src/topolo_agent_pack/execution.py +104 -7
  114. package/agent-packs/src/topolo_agent_pack/fresh.py +1 -1
  115. package/agent-packs/src/topolo_agent_pack/frontdoor.py +23 -0
  116. package/agent-packs/src/topolo_agent_pack/linked_task_context.py +80 -0
  117. package/agent-packs/src/topolo_agent_pack/live.py +285 -18
  118. package/agent-packs/src/topolo_agent_pack/native.py +160 -4
  119. package/agent-packs/src/topolo_agent_pack/policy.py +8 -0
  120. package/agent-packs/src/topolo_agent_pack/review_context.py +33 -0
  121. package/agent-packs/src/topolo_agent_pack/role_catalog.py +137 -0
  122. package/agent-packs/src/topolo_agent_pack/role_context.py +63 -0
  123. package/agent-packs/src/topolo_agent_pack/role_refresh.py +50 -0
  124. package/agent-packs/src/topolo_agent_pack/task_models.py +11 -7
  125. package/agent-packs/src/topolo_agent_pack/throughput.py +149 -0
  126. package/agent-packs/src/topolo_agent_pack/topolo.py +7 -1
  127. package/agent-packs/src/topolo_agent_pack/work.py +322 -0
  128. package/agent-packs/src/topolo_agent_pack/work_adoption.py +122 -0
  129. package/agent-packs/src/topolo_agent_pack/work_artifacts.py +84 -0
  130. package/agent-packs/src/topolo_agent_pack/work_context.py +77 -0
  131. package/agent-packs/src/topolo_agent_pack/work_gui.py +51 -0
  132. package/agent-packs/src/topolo_agent_pack/work_native.py +414 -0
  133. package/agent-packs/src/topolo_agent_pack/work_wait.py +151 -0
  134. package/agent-packs/templates/topolo-coordinators/AGENTS.md +7 -0
  135. package/agent-packs/templates/topolo-coordinators/agents/intakes/cicd/AGENTS.md +6 -0
  136. package/agent-packs/templates/topolo-coordinators/agents/intakes/cicd/config.json +4 -0
  137. package/agent-packs/templates/topolo-coordinators/agents/intakes/engineering/AGENTS.md +3 -0
  138. package/agent-packs/templates/topolo-coordinators/agents/intakes/engineering/config.json +4 -0
  139. package/agent-packs/templates/topolo-coordinators/agents/intakes/operator-01/AGENTS.md +3 -0
  140. package/agent-packs/templates/topolo-coordinators/agents/intakes/operator-01/config.json +4 -0
  141. package/agent-packs/templates/topolo-coordinators/pack.json +56 -0
  142. package/dist/chunk-NYDIAYVY.js +144 -0
  143. package/dist/{claude-pack-backend-YEVPAOID.js → chunk-UY5LEU5C.js} +1659 -21
  144. package/dist/claude-desktop-profile-JCQKSY3K.js +13 -0
  145. package/dist/claude-pack-activation-Q25MCEPU.js +506 -0
  146. package/dist/claude-pack-backend-HUTKGIDS.js +90 -0
  147. package/dist/index.js +408 -164
  148. package/package.json +8 -6
@@ -0,0 +1,207 @@
1
+ """Single-use supported Claude Code worker, without a terminal or saved history.
2
+
3
+ The Desktop front doors remain separate. This adapter retires a process, not a
4
+ Desktop chat, and never represents a CLI process exit as native chat archival.
5
+ """
6
+ import asyncio
7
+ import json
8
+ import os
9
+ import pathlib
10
+ import shlex
11
+ import signal
12
+ import sys
13
+ import uuid
14
+
15
+ from .claude import find_binary
16
+ from .storage import atomic_json, read_json, PackError
17
+ from .work import require
18
+
19
+
20
+ def public_progress(text):
21
+ if not text.strip() or '```' in text:
22
+ return False
23
+ try:
24
+ json.loads(text)
25
+ except ValueError:
26
+ return True
27
+ return False
28
+
29
+
30
+ def hook(directory, event):
31
+ directory = pathlib.Path(directory)
32
+ gate = read_json(directory / 'gate.json')
33
+ require(event.get('session_id') == gate['sessionId'], 'Worker hook session differs')
34
+ kind = event.get('hook_event_name')
35
+ if kind == 'PreToolUse':
36
+ allowed = gate['phase'] == 'assigned' and gate.get('allowTools') is True and event.get('permission_mode') == 'bypassPermissions'
37
+ allowed = allowed and event.get('effort', {}).get('level') == gate['effort']
38
+ allowed = allowed and event.get('tool_name') not in {'Agent', 'Task', 'Skill', 'EnterWorktree', 'ExitWorktree'}
39
+ return {'hookSpecificOutput': {'hookEventName': kind,
40
+ 'permissionDecision': 'allow' if allowed else 'deny',
41
+ 'permissionDecisionReason': 'Topolo bounded worker phase and native effort gate'}}
42
+ require(kind in {'UserPromptSubmit', 'Stop'}, 'Unsupported worker hook')
43
+ with (directory / 'native-settings.jsonl').open('a') as stream:
44
+ stream.write(json.dumps({k: event.get(k) for k in
45
+ ['session_id', 'prompt_id', 'hook_event_name', 'permission_mode', 'effort']}) + '\n')
46
+ return {}
47
+
48
+
49
+ def hook_main(directory):
50
+ try:
51
+ raw = sys.stdin.read(100001)
52
+ require(len(raw) <= 100000, 'Worker hook input exceeds its boundary')
53
+ print(json.dumps(hook(directory, json.loads(raw))))
54
+ return 0
55
+ except Exception as exc:
56
+ print('Topolo worker gate refused: ' + str(exc), file=sys.stderr)
57
+ return 2
58
+
59
+
60
+ class Session:
61
+ def __init__(self, cwd, worker_id, model, effort, progress=None):
62
+ require(str(uuid.UUID(worker_id)) == worker_id, 'Exact worker UUID required')
63
+ self.cwd = pathlib.Path(cwd)
64
+ self.id = worker_id
65
+ self.model = model
66
+ self.effort = effort
67
+ self.progress = progress
68
+ self.directory = self.cwd / '.topolo-worker'
69
+ self.directory.mkdir(mode=0o700)
70
+ self.process = None
71
+ self.pending = None
72
+ self.results = {}
73
+ self.init = None
74
+ self.closed = False
75
+ self.assigned = False
76
+ self.allow_tools = True
77
+ self.expected_email = None
78
+ self.auth_config_directory = None
79
+ self.tool_path = None
80
+
81
+ async def start(self):
82
+ binary = find_binary()
83
+ require(binary is not None, 'Signed-in Claude Code runtime is required')
84
+ gate = {'sessionId': self.id, 'model': self.model, 'effort': self.effort, 'phase': 'readiness'}
85
+ atomic_json(self.directory / 'gate.json', gate)
86
+ adapter = pathlib.Path(__file__).resolve().parents[2] / 'topolo-agent-pack'
87
+ require(adapter.is_file(), 'Installed bundled worker-hook entrypoint is unavailable')
88
+ command = shlex.join([sys.executable, str(adapter), 'work', 'claude-worker-hook',
89
+ '--harness', 'claude', '--input', str(self.directory)]) + ' || exit 2'
90
+ settings = {'autoMemoryEnabled': False, 'hooks': {
91
+ event: [{'hooks': [{'type': 'command', 'command': command, 'timeout': 15}]}]
92
+ for event in ['UserPromptSubmit', 'Stop', 'PreToolUse']}}
93
+ atomic_json(self.directory / 'settings.json', settings)
94
+ env = dict(os.environ)
95
+ # Use the explicitly bound native profile's supported subscription login;
96
+ # never copy a token or silently select default-profile/API-key billing.
97
+ require(not any(env.get(k) for k in ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN',
98
+ 'CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_BASE_URL']),
99
+ 'Explicit provider or credential overrides are outside this subscription worker binding')
100
+ require(self.auth_config_directory is not None, 'An explicitly bound Claude authentication profile is required')
101
+ env['CLAUDE_CONFIG_DIR'] = self.auth_config_directory
102
+ env.pop('CLAUDECODE', None)
103
+ env['PYTHONPATH'] = str(pathlib.Path(__file__).resolve().parent.parent)
104
+ if self.tool_path is not None:
105
+ env['PATH'] = self.tool_path
106
+ auth = await asyncio.create_subprocess_exec(binary, 'auth', 'status', '--json', env=env,
107
+ stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL)
108
+ try:
109
+ stdout, _ = await asyncio.wait_for(auth.communicate(), 15)
110
+ finally:
111
+ if auth.returncode is None:
112
+ auth.kill()
113
+ await auth.wait()
114
+ account = json.loads(stdout)
115
+ require(auth.returncode == 0 and account.get('loggedIn') is True and account.get('authMethod') == 'claude.ai'
116
+ and self.expected_email is not None and account.get('email') == self.expected_email,
117
+ 'The signed-in Claude subscription account differs from the installation')
118
+ stderr = (self.directory / 'stderr.log').open('wb')
119
+ try:
120
+ self.process = await asyncio.create_subprocess_exec(binary, '--print',
121
+ '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose',
122
+ '--replay-user-messages', '--session-id', self.id, '--no-session-persistence',
123
+ '--model', self.model, '--effort', self.effort, '--permission-mode', 'bypassPermissions',
124
+ '--setting-sources', '', '--settings', str(self.directory / 'settings.json'),
125
+ '--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}',
126
+ '--append-system-prompt-file', str(self.cwd / 'CLAUDE.md'), cwd=self.cwd, env=env,
127
+ stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=stderr,
128
+ limit=2 * 1024 * 1024, start_new_session=True)
129
+ finally:
130
+ stderr.close()
131
+ ready = 'TOPOLO_WORKER_READY:' + self.id
132
+ result = await self.exchange(self.id, 'Readiness only. Do not use tools or perform work. Reply exactly ' + ready)
133
+ require(result['output'].strip() == ready and result['toolItems'] == 0,
134
+ 'The exact tool-free readiness probe did not complete')
135
+ proofs = [json.loads(line) for line in (self.directory / 'native-settings.jsonl').read_text().splitlines()]
136
+ require(any(p['hook_event_name'] == 'UserPromptSubmit' and p['permission_mode'] == 'bypassPermissions' for p in proofs)
137
+ and any(p['hook_event_name'] == 'Stop' and isinstance(p['effort'], dict)
138
+ and p['effort'].get('level') == self.effort for p in proofs),
139
+ 'Actual native permission and effort acknowledgement is unavailable')
140
+ return {'model': self.model, 'effort': self.effort, 'sessionId': self.id,
141
+ 'processId': self.process.pid, 'boundary': 'native init, readiness result and Stop hook'}
142
+
143
+ async def assign(self, message_id, prompt):
144
+ require(not self.assigned and self.pending is None, 'This worker already has an assignment; reconcile without resend')
145
+ self.assigned = True
146
+ atomic_json(self.directory / 'gate.json', {'sessionId': self.id, 'model': self.model,
147
+ 'effort': self.effort, 'phase': 'assigned', 'allowTools': self.allow_tools})
148
+ return await self.exchange(message_id, prompt)
149
+
150
+ async def exchange(self, message_id, prompt):
151
+ require(self.process is not None and not self.closed and self.pending is None,
152
+ 'Native worker is not ready for one exact input')
153
+ self.pending = message_id
154
+ # A create-only marker precedes stdin delivery. Lost acknowledgements
155
+ # retain the UUID; neither startup nor exchange retries it.
156
+ with (self.directory / (message_id + '.send.json')).open('x') as stream:
157
+ json.dump({'sessionId': self.id, 'messageId': message_id}, stream)
158
+ stream.flush()
159
+ os.fsync(stream.fileno())
160
+ value = {'type': 'user', 'session_id': self.id, 'uuid': message_id,
161
+ 'message': {'role': 'user', 'content': prompt}}
162
+ self.process.stdin.write((json.dumps(value) + '\n').encode())
163
+ await self.process.stdin.drain()
164
+ echoed = False
165
+ tools = 0
166
+ async with asyncio.timeout(1800):
167
+ while line := await self.process.stdout.readline():
168
+ row = json.loads(line)
169
+ require(row.get('session_id', self.id) == self.id, 'Native event belongs to another session')
170
+ if row.get('type') == 'system' and row.get('subtype') == 'init':
171
+ require(self.init is None and row.get('model') == self.model
172
+ and row.get('permissionMode') == 'bypassPermissions', 'Native worker startup tuple differs')
173
+ self.init = row
174
+ if row.get('type') == 'user' and row.get('uuid') == message_id:
175
+ require(not echoed, 'Native input acknowledgement is duplicated')
176
+ echoed = True
177
+ if row.get('type') == 'assistant':
178
+ content = row.get('message', {}).get('content', [])
179
+ tools += sum(item.get('type') == 'tool_use' for item in content)
180
+ if self.assigned and self.progress:
181
+ text = '\n'.join(item['text'] for item in content if item.get('type') == 'text')
182
+ if public_progress(text):
183
+ await self.progress(row['uuid'], text[:1600])
184
+ if row.get('type') == 'result':
185
+ require(self.init is not None and echoed and row.get('is_error') is False
186
+ and row.get('subtype') == 'success' and row.get('terminal_reason') == 'completed'
187
+ and row.get('stop_reason') == 'end_turn' and not row.get('permission_denials')
188
+ and set(row.get('modelUsage', {})) == {self.model}
189
+ and row.get('queued_turn_count', 0) == 0 and isinstance(row.get('result'), str),
190
+ 'Native worker did not prove a successful selected-model turn: ' + str(row.get('result', ''))[:300])
191
+ receipt = {'id': message_id, 'status': 'completed', 'output': row['result'],
192
+ 'toolItems': tools, 'nativeSessionId': self.id, 'nativeResultId': row['uuid']}
193
+ self.results[message_id] = receipt
194
+ self.pending = None
195
+ return receipt
196
+ raise PackError('Native worker ended without its exact terminal receipt; no resend')
197
+
198
+ async def close(self):
199
+ if self.process is not None and self.process.returncode is None:
200
+ self.process.stdin.close()
201
+ try:
202
+ await asyncio.wait_for(self.process.wait(), 8)
203
+ except TimeoutError:
204
+ os.killpg(self.process.pid, signal.SIGTERM)
205
+ await asyncio.wait_for(self.process.wait(), 8)
206
+ self.closed = True
207
+ return {'state': 'process-retired', 'sessionId': self.id, 'desktopChatArchived': False}
@@ -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')