pairling 0.0.1 → 0.1.0

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 (61) hide show
  1. package/package.json +5 -1
  2. package/payload/mac/SOURCE_BRANCH +1 -0
  3. package/payload/mac/SOURCE_DIRTY +1 -0
  4. package/payload/mac/SOURCE_REVISION +1 -0
  5. package/payload/mac/VERSION +1 -0
  6. package/payload/mac/companiond/integrations/__init__.py +1 -0
  7. package/payload/mac/companiond/integrations/aperture_cli/__init__.py +23 -0
  8. package/payload/mac/companiond/integrations/aperture_cli/launch.py +456 -0
  9. package/payload/mac/companiond/integrations/aperture_cli/status.py +393 -0
  10. package/payload/mac/companiond/live_activity_publisher.py +380 -0
  11. package/payload/mac/companiond/llm_route.py +108 -0
  12. package/payload/mac/companiond/local_mcp_bridge.py +156 -0
  13. package/payload/mac/companiond/model_status_contract.py +101 -0
  14. package/payload/mac/companiond/pairdrop_store.py +920 -0
  15. package/payload/mac/companiond/pairling_connectd_status.py +149 -0
  16. package/payload/mac/companiond/pairling_devices.py +459 -0
  17. package/payload/mac/companiond/pairling_pairing.py +404 -0
  18. package/payload/mac/companiond/pairling_relay_claims.py +232 -0
  19. package/payload/mac/companiond/pairling_tools.py +706 -0
  20. package/payload/mac/companiond/pairlingd.py +18438 -0
  21. package/payload/mac/companiond/providers/__init__.py +1 -0
  22. package/payload/mac/companiond/providers/base.py +255 -0
  23. package/payload/mac/companiond/providers/claude.py +127 -0
  24. package/payload/mac/companiond/providers/codex.py +124 -0
  25. package/payload/mac/companiond/providers/external.py +46 -0
  26. package/payload/mac/companiond/providers/registry.py +70 -0
  27. package/payload/mac/companiond/pty_broker.py +887 -0
  28. package/payload/mac/companiond/push_dispatcher.py +1990 -0
  29. package/payload/mac/companiond/push_event_catalog.py +566 -0
  30. package/payload/mac/companiond/request_proof.py +142 -0
  31. package/payload/mac/companiond/runtime_contract.py +47 -0
  32. package/payload/mac/companiond/runtime_manifest.py +197 -0
  33. package/payload/mac/companiond/runtime_paths.py +87 -0
  34. package/payload/mac/companiond/safety_monitor.py +542 -0
  35. package/payload/mac/companiond/sentinel_notifications.py +491 -0
  36. package/payload/mac/companiond/standard_push_publisher.py +516 -0
  37. package/payload/mac/companiond/substrate_status_contract.py +139 -0
  38. package/payload/mac/companiond/terminal_screen_backend.py +332 -0
  39. package/payload/mac/companiond/terminal_text_sanitizer.py +54 -0
  40. package/payload/mac/companiond/workstate_feed_contract.py +108 -0
  41. package/payload/mac/connectd/cmd/pairling-connectd/auth_open_test.go +116 -0
  42. package/payload/mac/connectd/cmd/pairling-connectd/main.go +345 -0
  43. package/payload/mac/connectd/cmd/pairling-connectd/upstream_health_test.go +33 -0
  44. package/payload/mac/connectd/go.mod +51 -0
  45. package/payload/mac/connectd/go.sum +229 -0
  46. package/payload/mac/connectd/internal/gateway/proxy.go +597 -0
  47. package/payload/mac/connectd/internal/gateway/proxy_test.go +531 -0
  48. package/payload/mac/connectd/internal/runtime/config.go +99 -0
  49. package/payload/mac/connectd/internal/runtime/config_test.go +29 -0
  50. package/payload/mac/connectd/internal/status/status.go +300 -0
  51. package/payload/mac/connectd/internal/status/status_test.go +263 -0
  52. package/payload/mac/guardian/companion-power-guardian.py +613 -0
  53. package/payload/mac/guardian/guardian_contract.py +67 -0
  54. package/payload/mac/install/bootstrap-first-run.sh +206 -0
  55. package/payload/mac/install/doctor.sh +660 -0
  56. package/payload/mac/install/install-runtime.sh +1241 -0
  57. package/payload/mac/install/render-launchd.py +119 -0
  58. package/payload/mac/install/uninstall-runtime.sh +136 -0
  59. package/payload/mac/mcp/phone_tools.py +210 -0
  60. package/payload/mac/packaging/bin/pairling +63 -0
  61. package/payload-manifest.json +255 -0
@@ -0,0 +1,566 @@
1
+ #!/usr/bin/env python3
2
+ """Bounded Pairling APNs and Live Activity event catalog.
3
+
4
+ This module owns the user-facing push copy and APNs-safe Live Activity state.
5
+ It intentionally accepts only bounded semantic labels; raw transcript, prompt,
6
+ command output, credentials, filesystem dumps, and stack traces are ignored.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ import re
14
+ import time
15
+ from dataclasses import dataclass
16
+ from typing import Any
17
+
18
+
19
+ SUPPORTED_KINDS = {
20
+ "action_required",
21
+ "turn_result",
22
+ "turn_failed",
23
+ "tool_risk",
24
+ "mac_route_risk",
25
+ "worker_pressure",
26
+ "deploy_result",
27
+ "push_diagnostic",
28
+ }
29
+ CATALOG_KINDS = tuple(sorted(SUPPORTED_KINDS))
30
+ LEGACY_KIND_ALIASES = {
31
+ "turn_done": "turn_result",
32
+ "session_attention": "action_required",
33
+ "mac_health": "mac_route_risk",
34
+ "worker_sentinel": "worker_pressure",
35
+ }
36
+
37
+ PHASE_BY_KIND = {
38
+ "action_required": "attention",
39
+ "turn_result": "done",
40
+ "turn_failed": "failed",
41
+ "tool_risk": "risk",
42
+ "mac_route_risk": "risk",
43
+ "worker_pressure": "attention",
44
+ "deploy_result": "done",
45
+ "push_diagnostic": "done",
46
+ }
47
+
48
+ INTERRUPTION_BY_KIND = {
49
+ "action_required": "time-sensitive",
50
+ "turn_result": "active",
51
+ "turn_failed": "time-sensitive",
52
+ "tool_risk": "time-sensitive",
53
+ "mac_route_risk": "time-sensitive",
54
+ "worker_pressure": "time-sensitive",
55
+ "deploy_result": "active",
56
+ "push_diagnostic": "passive",
57
+ }
58
+
59
+ ALERT_COPY = {
60
+ "action_required": ("Pairling needs approval", "Review the requested action before work continues."),
61
+ "turn_result": ("Pairling turn complete", "A turn finished with a useful result."),
62
+ "turn_failed": ("Pairling turn failed", "A turn failed and needs review."),
63
+ "tool_risk": ("Pairling tool risk", "A tool signal needs review."),
64
+ "mac_route_risk": ("Mac route timed out", "The paired Mac route needs attention."),
65
+ "worker_pressure": ("Pairling worker pressure", "Worker or token pressure needs review."),
66
+ "deploy_result": ("Deploy result ready", "A build or deploy result is available."),
67
+ "push_diagnostic": ("Pairling push test", "Push delivery is configured for this device."),
68
+ }
69
+
70
+ PRIVATE_INPUT_KEYS = {
71
+ "raw_transcript",
72
+ "transcript",
73
+ "transcript_text",
74
+ "prompt",
75
+ "prompt_text",
76
+ "command_output",
77
+ "stdout",
78
+ "stderr",
79
+ "credentials",
80
+ "api_key",
81
+ "provider_api_key",
82
+ "filesystem_dump",
83
+ "stack_trace",
84
+ "traceback",
85
+ }
86
+
87
+
88
+ @dataclass
89
+ class PushEvent:
90
+ kind: str
91
+ event_id: str | None = None
92
+ source: str = "unknown"
93
+ provider: str | None = None
94
+ session_id: str | None = None
95
+ project: str | None = None
96
+ session_title: str | None = None
97
+ observed_at: float | None = None
98
+ created_at: float | None = None
99
+ phase: str | None = None
100
+ status: str | None = None
101
+ current_step: str | None = None
102
+ latest_event: str | None = None
103
+ result_summary: str | None = None
104
+ required_action: str | None = None
105
+ risk_level: str | None = None
106
+ risk_summary: str | None = None
107
+ route_health: str | None = None
108
+ worker_summary: str | None = None
109
+ build_label: str | None = None
110
+ action_route: str | None = None
111
+ freshness_seconds: int | None = None
112
+ privacy_tier: str = "standard"
113
+ dedupe_key: str | None = None
114
+ thread_id: str | None = None
115
+ collapse_id: str | None = None
116
+
117
+ def __post_init__(self) -> None:
118
+ normalized = catalog_event(
119
+ kind=self.kind,
120
+ event_id=self.event_id,
121
+ source=self.source,
122
+ provider=self.provider,
123
+ session_id=self.session_id,
124
+ project=self.project,
125
+ session_title=self.session_title,
126
+ observed_at=self.observed_at,
127
+ created_at=self.created_at,
128
+ phase=self.phase,
129
+ status=self.status,
130
+ current_step=self.current_step,
131
+ latest_event=self.latest_event,
132
+ result_summary=self.result_summary,
133
+ required_action=self.required_action,
134
+ risk_level=self.risk_level,
135
+ risk_summary=self.risk_summary,
136
+ route_health=self.route_health,
137
+ worker_summary=self.worker_summary,
138
+ build_label=self.build_label,
139
+ action_route=self.action_route,
140
+ freshness_seconds=self.freshness_seconds,
141
+ privacy_tier=self.privacy_tier,
142
+ dedupe_key=self.dedupe_key,
143
+ thread_id=self.thread_id,
144
+ collapse_id=self.collapse_id,
145
+ _skip_push_event_init=True,
146
+ )
147
+ self.__dict__.update(normalized.__dict__)
148
+
149
+
150
+ def catalog_event(
151
+ *,
152
+ kind: str,
153
+ source: str,
154
+ provider: str | None = None,
155
+ session_id: str | None = None,
156
+ project: str | None = None,
157
+ session_title: str | None = None,
158
+ observed_at: float | None = None,
159
+ created_at: float | None = None,
160
+ phase: str | None = None,
161
+ status: str | None = None,
162
+ current_step: str | None = None,
163
+ latest_event: str | None = None,
164
+ result_summary: str | None = None,
165
+ required_action: str | None = None,
166
+ risk_level: str | None = None,
167
+ risk_summary: str | None = None,
168
+ route_health: str | None = None,
169
+ worker_summary: str | None = None,
170
+ build_label: str | None = None,
171
+ action_route: str | None = None,
172
+ freshness_seconds: int | None = None,
173
+ privacy_tier: str = "standard",
174
+ dedupe_key: str | None = None,
175
+ thread_id: str | None = None,
176
+ collapse_id: str | None = None,
177
+ event_id: str | None = None,
178
+ _skip_push_event_init: bool = False,
179
+ **ignored_private_inputs: Any,
180
+ ) -> PushEvent:
181
+ """Build a bounded APNs-safe event.
182
+
183
+ Unknown keyword inputs are intentionally ignored, which lets callers pass a
184
+ larger observed event without accidentally forwarding private material.
185
+ """
186
+
187
+ del ignored_private_inputs
188
+ normalized_kind = _canonical_kind(kind)
189
+ now = float(created_at if created_at is not None else time.time())
190
+ observed = float(observed_at if observed_at is not None else now)
191
+ normalized_phase = _bounded_enum(phase or PHASE_BY_KIND[normalized_kind], 32)
192
+ normalized_status = _bounded_enum(status or normalized_phase, 32)
193
+ normalized_provider = _bounded_label(provider, 32)
194
+ normalized_session_id = _bounded_id(session_id)
195
+ route = _safe_route(action_route, session_id=normalized_session_id, kind=normalized_kind)
196
+ normalized_project = _bounded_label(_project_name(project), 60)
197
+ normalized_title = _bounded_label(session_title, 60)
198
+ normalized_result = _bounded_summary(result_summary)
199
+ normalized_required = _bounded_summary(required_action)
200
+ normalized_risk = _bounded_enum(risk_level, 32)
201
+ normalized_risk_summary = _bounded_summary(risk_summary)
202
+ normalized_route_health = _bounded_summary(route_health)
203
+ normalized_worker_summary = _bounded_summary(worker_summary)
204
+ normalized_build_label = _bounded_label(build_label, 60)
205
+ normalized_current_step = _bounded_label(current_step, 60)
206
+ normalized_latest = _bounded_summary(latest_event) or _latest_event(
207
+ normalized_kind,
208
+ current_step=normalized_current_step,
209
+ result_summary=normalized_result,
210
+ required_action=normalized_required,
211
+ risk_summary=normalized_risk_summary,
212
+ worker_summary=normalized_worker_summary,
213
+ )
214
+ fresh = _bounded_int(freshness_seconds, minimum=0, maximum=86_400) if freshness_seconds is not None else None
215
+ stable = {
216
+ "kind": normalized_kind,
217
+ "session_id": normalized_session_id,
218
+ "project": normalized_project,
219
+ "status": normalized_status,
220
+ "result": normalized_result,
221
+ "required": normalized_required,
222
+ "risk": normalized_risk_summary,
223
+ "observed_bucket": int(observed),
224
+ }
225
+ normalized_dedupe = _bounded_id(dedupe_key) or _stable_hash(stable)[:32]
226
+ normalized_thread = _bounded_id(thread_id) or _default_thread_id(normalized_kind, normalized_session_id)
227
+ normalized_collapse = _bounded_id(collapse_id) or f"pairling.{normalized_kind}.{normalized_dedupe}"[:160]
228
+ normalized_event_id = _bounded_id(event_id) or f"push_{normalized_kind}_{_stable_hash(stable)[:24]}"
229
+ if _skip_push_event_init:
230
+ event = object.__new__(PushEvent)
231
+ values = {
232
+ "event_id": normalized_event_id,
233
+ "kind": normalized_kind,
234
+ "source": _bounded_enum(source, 32) or "unknown",
235
+ "provider": normalized_provider,
236
+ "session_id": normalized_session_id,
237
+ "project": normalized_project,
238
+ "session_title": normalized_title,
239
+ "observed_at": observed,
240
+ "created_at": now,
241
+ "phase": normalized_phase,
242
+ "status": normalized_status,
243
+ "current_step": normalized_current_step,
244
+ "latest_event": normalized_latest,
245
+ "result_summary": normalized_result,
246
+ "required_action": normalized_required,
247
+ "risk_level": normalized_risk,
248
+ "risk_summary": normalized_risk_summary,
249
+ "route_health": normalized_route_health,
250
+ "worker_summary": normalized_worker_summary,
251
+ "build_label": normalized_build_label,
252
+ "action_route": route,
253
+ "freshness_seconds": fresh,
254
+ "privacy_tier": _bounded_enum(privacy_tier, 32) or "standard",
255
+ "dedupe_key": normalized_dedupe,
256
+ "thread_id": normalized_thread,
257
+ "collapse_id": normalized_collapse,
258
+ }
259
+ event.__dict__.update(values)
260
+ return event
261
+ return PushEvent(
262
+ event_id=normalized_event_id,
263
+ kind=normalized_kind,
264
+ source=_bounded_enum(source, 32) or "unknown",
265
+ provider=normalized_provider,
266
+ session_id=normalized_session_id,
267
+ project=normalized_project,
268
+ session_title=normalized_title,
269
+ observed_at=observed,
270
+ created_at=now,
271
+ phase=normalized_phase,
272
+ status=normalized_status,
273
+ current_step=normalized_current_step,
274
+ latest_event=normalized_latest,
275
+ result_summary=normalized_result,
276
+ required_action=normalized_required,
277
+ risk_level=normalized_risk,
278
+ risk_summary=normalized_risk_summary,
279
+ route_health=normalized_route_health,
280
+ worker_summary=normalized_worker_summary,
281
+ build_label=normalized_build_label,
282
+ action_route=route,
283
+ freshness_seconds=fresh,
284
+ privacy_tier=_bounded_enum(privacy_tier, 32) or "standard",
285
+ dedupe_key=normalized_dedupe,
286
+ thread_id=normalized_thread,
287
+ collapse_id=normalized_collapse,
288
+ )
289
+
290
+
291
+ def build_push_event(**kwargs: Any) -> PushEvent:
292
+ return catalog_event(**kwargs)
293
+
294
+
295
+ def standard_alert_payload(event: PushEvent) -> dict[str, Any]:
296
+ title, default_body = ALERT_COPY[event.kind]
297
+ if event.kind == "action_required" and event.required_action:
298
+ body = _join_sentence(event.required_action, event.session_title or event.project)
299
+ elif event.kind in {"turn_result", "deploy_result"} and event.result_summary:
300
+ body = _join_sentence(event.result_summary, event.build_label or event.session_title or event.project)
301
+ elif event.kind in {"turn_failed", "tool_risk", "mac_route_risk"} and (event.risk_summary or event.current_step):
302
+ body = _join_sentence(event.risk_summary or event.current_step, event.route_health or event.session_title or event.project)
303
+ elif event.kind == "worker_pressure" and event.worker_summary:
304
+ body = _join_sentence(event.worker_summary, event.required_action or "Review workers.")
305
+ else:
306
+ body = default_body
307
+ payload = {
308
+ "event_id": event.event_id,
309
+ "kind": event.kind,
310
+ "route": event.action_route or "pairling://dashboard",
311
+ "title": _bounded_label(_title_for_event(event, title), 60) or title,
312
+ "body": _bounded_summary(body) or default_body,
313
+ "thread_id": event.thread_id,
314
+ "collapse_id": event.collapse_id,
315
+ "interruption_level": INTERRUPTION_BY_KIND[event.kind],
316
+ "source": event.source,
317
+ "provider": event.provider,
318
+ "session_id": event.session_id,
319
+ "project": event.project,
320
+ "phase": event.phase,
321
+ "observed_at": event.observed_at,
322
+ "privacy_tier": event.privacy_tier,
323
+ }
324
+ payload.update(_semantic_fields(event))
325
+ return payload
326
+
327
+
328
+ def live_activity_payload(event: PushEvent) -> dict[str, Any]:
329
+ activity_event = "end" if event.phase == "done" else "update"
330
+ stale = 75
331
+ dismissal = 300 if event.required_action or event.phase in {"failed", "attention"} else 120
332
+ return {
333
+ "session_id": event.session_id or "",
334
+ "event": activity_event,
335
+ "event_id": event.event_id,
336
+ "content_state": live_activity_content_state(event),
337
+ "stale_seconds": stale,
338
+ "dismissal_seconds": dismissal,
339
+ "source": event.source,
340
+ "phase": event.phase,
341
+ "project": event.project,
342
+ "observed_at": event.observed_at,
343
+ "collapse_id": event.collapse_id,
344
+ "freshness_seconds": event.freshness_seconds,
345
+ }
346
+
347
+
348
+ def live_activity_content_state(event: PushEvent) -> dict[str, Any]:
349
+ state = event.phase
350
+ payload = {
351
+ "state": state,
352
+ "phase": event.phase,
353
+ "tool": event.current_step,
354
+ "effort": None,
355
+ "tokens": None,
356
+ "verb": _verb_for_phase(event.phase),
357
+ "attentionLevel": _attention_for_event(event),
358
+ "updatedAtEpoch": event.observed_at,
359
+ "eventId": event.event_id,
360
+ "sessionTitle": event.session_title,
361
+ "provider": event.provider,
362
+ "project": event.project,
363
+ "currentStep": event.current_step,
364
+ "latestEvent": event.latest_event,
365
+ "resultSummary": event.result_summary,
366
+ "requiredAction": event.required_action,
367
+ "freshness": _freshness_label(event.freshness_seconds),
368
+ "riskLevel": event.risk_level,
369
+ "riskSummary": event.risk_summary,
370
+ "routeHealth": event.route_health,
371
+ "workerSummary": event.worker_summary,
372
+ "buildLabel": event.build_label,
373
+ "actionRoute": event.action_route,
374
+ }
375
+ return {key: value for key, value in payload.items() if value is not None}
376
+
377
+
378
+ def outbox_metadata(
379
+ event: PushEvent,
380
+ *,
381
+ content_state: dict[str, Any] | None = None,
382
+ sent_at: float,
383
+ apns_outcome: str | None = None,
384
+ ) -> dict[str, Any]:
385
+ content_state = content_state or live_activity_content_state(event)
386
+ return {
387
+ "source": event.source,
388
+ "phase": event.phase,
389
+ "project": event.project,
390
+ "observed_at": event.observed_at,
391
+ "sent_at": float(sent_at),
392
+ "collapse_id": event.collapse_id,
393
+ "freshness_seconds_at_send": _freshness_at_send(event, sent_at),
394
+ "content_state_hash": _stable_hash(content_state),
395
+ "apns_outcome": _bounded_enum(apns_outcome, 80),
396
+ }
397
+
398
+
399
+ def _enum(value: Any, allowed: set[str], default: str) -> str:
400
+ text = str(value or "").strip()
401
+ return text if text in allowed else default
402
+
403
+
404
+ def _semantic_fields(event: PushEvent) -> dict[str, Any]:
405
+ fields = {
406
+ "status": event.status,
407
+ "current_step": event.current_step,
408
+ "latest_event": event.latest_event,
409
+ "result_summary": event.result_summary,
410
+ "required_action": event.required_action,
411
+ "risk_level": event.risk_level,
412
+ "risk_summary": event.risk_summary,
413
+ "route_health": event.route_health,
414
+ "worker_summary": event.worker_summary,
415
+ "build_label": event.build_label,
416
+ "action_route": event.action_route,
417
+ "freshness_seconds": event.freshness_seconds,
418
+ "dedupe_key": event.dedupe_key,
419
+ }
420
+ return {key: value for key, value in fields.items() if value not in (None, "")}
421
+
422
+
423
+ def _canonical_kind(value: Any) -> str:
424
+ text = str(value or "").strip()
425
+ text = LEGACY_KIND_ALIASES.get(text, text)
426
+ return _enum(text, SUPPORTED_KINDS, "push_diagnostic")
427
+
428
+
429
+ def _bounded_enum(value: Any, limit: int) -> str | None:
430
+ return _bounded_text(value, limit, strip_private=True)
431
+
432
+
433
+ def _bounded_id(value: Any) -> str | None:
434
+ text = _bounded_text(value, 160, strip_private=True)
435
+ if not text:
436
+ return None
437
+ return re.sub(r"[^A-Za-z0-9_.:/@#-]", "-", text)[:160]
438
+
439
+
440
+ def _bounded_label(value: Any, limit: int) -> str | None:
441
+ return _bounded_text(value, limit, strip_private=True)
442
+
443
+
444
+ def _bounded_summary(value: Any) -> str | None:
445
+ text = _bounded_text(value, 120, strip_private=True)
446
+ if not text:
447
+ return None
448
+ text = re.sub(r"(?i)traceback.*", "Failure details require review.", text).strip()
449
+ return text[:120] or None
450
+
451
+
452
+ def _bounded_text(value: Any, limit: int, *, strip_private: bool) -> str | None:
453
+ if value in (None, ""):
454
+ return None
455
+ text = str(value).replace("\r", " ").replace("\n", " ").strip()
456
+ if strip_private:
457
+ text = re.sub(r"(?i)(prompt|token|api[_-]?key|credential|password|secret)=?[^&\\s]*", "[redacted]", text)
458
+ text = re.sub(r"/Users/[^\\s?&]+", "[path]", text)
459
+ text = re.sub(r"(?i)traceback \\(most recent call last\\):.*", "Failure details require review.", text)
460
+ text = re.sub(r"\\s+", " ", text).strip()
461
+ return text[:limit] if text else None
462
+
463
+
464
+ def _safe_route(value: Any, *, session_id: str | None, kind: str) -> str:
465
+ text = _bounded_text(value, 300, strip_private=True)
466
+ if text and text.startswith("pairling://"):
467
+ return text
468
+ if session_id:
469
+ return "pairling://session/" + session_id
470
+ if kind == "worker_pressure":
471
+ return "pairling://workers"
472
+ if kind == "mac_route_risk":
473
+ return "pairling://health"
474
+ if kind == "push_diagnostic":
475
+ return "pairling://settings/push"
476
+ if kind == "deploy_result":
477
+ return "pairling://builds"
478
+ return "pairling://dashboard"
479
+
480
+
481
+ def _project_name(value: Any) -> str | None:
482
+ text = str(value or "").strip()
483
+ if not text:
484
+ return None
485
+ return text.rstrip("/").rsplit("/", 1)[-1] or text
486
+
487
+
488
+ def _latest_event(kind: str, **parts: Any) -> str | None:
489
+ for key in ["required_action", "risk_summary", "result_summary", "worker_summary", "current_step"]:
490
+ if parts.get(key):
491
+ return _bounded_summary(parts[key])
492
+ return ALERT_COPY[kind][1]
493
+
494
+
495
+ def _title_for_event(event: PushEvent, default: str) -> str:
496
+ if event.kind == "deploy_result" and event.build_label:
497
+ return f"{event.build_label} result"
498
+ if event.kind == "turn_result" and event.project:
499
+ return f"{event.project} turn complete"
500
+ return default
501
+
502
+
503
+ def _join_sentence(left: str | None, right: str | None) -> str:
504
+ if left and right:
505
+ return f"{left}. {right}."
506
+ return left or right or ""
507
+
508
+
509
+ def _verb_for_phase(phase: str) -> str:
510
+ return {
511
+ "attention": "Review",
512
+ "done": "Done",
513
+ "failed": "Failed",
514
+ "stale": "Stale",
515
+ "tool": "Using",
516
+ "responding": "Responding",
517
+ "starting": "Starting",
518
+ }.get(phase, "Thinking")
519
+
520
+
521
+ def _attention_for_event(event: PushEvent) -> str | None:
522
+ if event.phase in {"attention", "stale"}:
523
+ return "warning"
524
+ if event.phase == "failed" or event.risk_level == "critical":
525
+ return "critical"
526
+ return None
527
+
528
+
529
+ def _freshness_label(seconds: int | None) -> str | None:
530
+ if seconds is None:
531
+ return "now"
532
+ if seconds <= 0:
533
+ return "now"
534
+ if seconds < 60:
535
+ return f"{seconds}s ago"
536
+ return f"{seconds // 60}m ago"
537
+
538
+
539
+ def _freshness_at_send(event: PushEvent, sent_at: float) -> int:
540
+ if event.freshness_seconds is not None:
541
+ return int(event.freshness_seconds)
542
+ return max(0, int(float(sent_at) - float(event.observed_at)))
543
+
544
+
545
+ def _bounded_int(value: Any, *, minimum: int, maximum: int) -> int:
546
+ try:
547
+ parsed = int(value)
548
+ except (TypeError, ValueError):
549
+ parsed = minimum
550
+ return max(minimum, min(maximum, parsed))
551
+
552
+
553
+ def _default_thread_id(kind: str, session_id: str | None) -> str:
554
+ if session_id:
555
+ return ("pairling.session." + session_id)[:160]
556
+ if kind == "worker_pressure":
557
+ return "pairling.workers"
558
+ if kind == "mac_route_risk":
559
+ return "pairling.health"
560
+ if kind == "deploy_result":
561
+ return "pairling.deploy"
562
+ return "pairling.push"
563
+
564
+
565
+ def _stable_hash(payload: Any) -> str:
566
+ return hashlib.sha256(json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")).hexdigest()