meshcode 2.11.112__tar.gz → 2.11.114rc1__tar.gz

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 (99) hide show
  1. {meshcode-2.11.112 → meshcode-2.11.114rc1}/PKG-INFO +1 -1
  2. {meshcode-2.11.112 → meshcode-2.11.114rc1}/meshcode/__init__.py +1 -1
  3. meshcode-2.11.114rc1/meshcode/_session_handoff_template 3.py +296 -0
  4. meshcode-2.11.114rc1/meshcode/_session_handoff_template.py +296 -0
  5. meshcode-2.11.114rc1/meshcode/claude_update 3.py +258 -0
  6. meshcode-2.11.114rc1/meshcode/claude_update.py +258 -0
  7. meshcode-2.11.114rc1/meshcode/hostd 2.py +1269 -0
  8. {meshcode-2.11.112 → meshcode-2.11.114rc1}/meshcode/hostd.py +236 -0
  9. {meshcode-2.11.112 → meshcode-2.11.114rc1}/meshcode/run_agent.py +17 -0
  10. meshcode-2.11.114rc1/meshcode/up.py +257 -0
  11. {meshcode-2.11.112 → meshcode-2.11.114rc1}/meshcode.egg-info/PKG-INFO +1 -1
  12. {meshcode-2.11.112 → meshcode-2.11.114rc1}/meshcode.egg-info/SOURCES.txt +7 -37
  13. {meshcode-2.11.112 → meshcode-2.11.114rc1}/pyproject.toml +1 -1
  14. meshcode-2.11.112/tests/test_auto_update_hardening.py +0 -295
  15. meshcode-2.11.112/tests/test_autonomous_closegap_1.py +0 -164
  16. meshcode-2.11.112/tests/test_autonomous_closegap_2.py +0 -210
  17. meshcode-2.11.112/tests/test_autonomous_closegap_3.py +0 -163
  18. meshcode-2.11.112/tests/test_autonomous_prompt_inject.py +0 -126
  19. meshcode-2.11.112/tests/test_boot_bug_regression.py +0 -205
  20. meshcode-2.11.112/tests/test_color_truecolor.py +0 -83
  21. meshcode-2.11.112/tests/test_core.py +0 -216
  22. meshcode-2.11.112/tests/test_cross_agent_messaging.py +0 -366
  23. meshcode-2.11.112/tests/test_date_parse.py +0 -112
  24. meshcode-2.11.112/tests/test_doctor.py +0 -123
  25. meshcode-2.11.112/tests/test_epistemic_v1_python_sdk.py +0 -177
  26. meshcode-2.11.112/tests/test_epistemic_v1_stop_conditions.py +0 -158
  27. meshcode-2.11.112/tests/test_esc_deaf_state.py +0 -361
  28. meshcode-2.11.112/tests/test_exceptions.py +0 -107
  29. meshcode-2.11.112/tests/test_file_upload.py +0 -171
  30. meshcode-2.11.112/tests/test_init_device_code.py +0 -68
  31. meshcode-2.11.112/tests/test_install_guard.py +0 -170
  32. meshcode-2.11.112/tests/test_lease_sigterm_release.py +0 -299
  33. meshcode-2.11.112/tests/test_mark_read_batch.py +0 -200
  34. meshcode-2.11.112/tests/test_marketplace_ratings.py +0 -174
  35. meshcode-2.11.112/tests/test_migration_integrity.py +0 -176
  36. meshcode-2.11.112/tests/test_realtime_event_freshness.py +0 -236
  37. meshcode-2.11.112/tests/test_rls_cross_tenant.py +0 -255
  38. meshcode-2.11.112/tests/test_rpc_grants.py +0 -76
  39. meshcode-2.11.112/tests/test_rpc_migrations.py +0 -452
  40. meshcode-2.11.112/tests/test_run_agent_dry_run.py +0 -128
  41. meshcode-2.11.112/tests/test_run_agent_no_server_import.py +0 -85
  42. meshcode-2.11.112/tests/test_security_regressions.py +0 -171
  43. meshcode-2.11.112/tests/test_self_update_user_site.py +0 -139
  44. meshcode-2.11.112/tests/test_sentinel.py +0 -148
  45. meshcode-2.11.112/tests/test_setup_path.py +0 -66
  46. meshcode-2.11.112/tests/test_sleep_signals.py +0 -160
  47. meshcode-2.11.112/tests/test_status_enum_coverage.py +0 -231
  48. meshcode-2.11.112/tests/test_stay_on_loop_hook.py +0 -302
  49. meshcode-2.11.112/tests/test_wait_open_tasks_contradiction.py +0 -87
  50. {meshcode-2.11.112 → meshcode-2.11.114rc1}/README.md +0 -0
  51. {meshcode-2.11.112 → meshcode-2.11.114rc1}/meshcode/__main__.py +0 -0
  52. /meshcode-2.11.112/meshcode/_session_handoff_template.py → /meshcode-2.11.114rc1/meshcode/_session_handoff_template 2.py +0 -0
  53. {meshcode-2.11.112 → meshcode-2.11.114rc1}/meshcode/_stop_hook_template.py +0 -0
  54. {meshcode-2.11.112 → meshcode-2.11.114rc1}/meshcode/ascii_art.py +0 -0
  55. {meshcode-2.11.112 → meshcode-2.11.114rc1}/meshcode/atomic_push.py +0 -0
  56. /meshcode-2.11.112/meshcode/claude_update.py → /meshcode-2.11.114rc1/meshcode/claude_update 2.py +0 -0
  57. {meshcode-2.11.112 → meshcode-2.11.114rc1}/meshcode/cli.py +0 -0
  58. {meshcode-2.11.112 → meshcode-2.11.114rc1}/meshcode/comms_v4.py +0 -0
  59. {meshcode-2.11.112 → meshcode-2.11.114rc1}/meshcode/compat.py +0 -0
  60. {meshcode-2.11.112 → meshcode-2.11.114rc1}/meshcode/daemon.py +0 -0
  61. {meshcode-2.11.112 → meshcode-2.11.114rc1}/meshcode/date_parse.py +0 -0
  62. {meshcode-2.11.112 → meshcode-2.11.114rc1}/meshcode/doctor.py +0 -0
  63. {meshcode-2.11.112 → meshcode-2.11.114rc1}/meshcode/error_hints.py +0 -0
  64. {meshcode-2.11.112 → meshcode-2.11.114rc1}/meshcode/exceptions.py +0 -0
  65. {meshcode-2.11.112 → meshcode-2.11.114rc1}/meshcode/hooks/__init__.py +0 -0
  66. {meshcode-2.11.112 → meshcode-2.11.114rc1}/meshcode/hooks/repo_path_lock.py +0 -0
  67. {meshcode-2.11.112 → meshcode-2.11.114rc1}/meshcode/invites.py +0 -0
  68. {meshcode-2.11.112 → meshcode-2.11.114rc1}/meshcode/launcher.py +0 -0
  69. {meshcode-2.11.112 → meshcode-2.11.114rc1}/meshcode/launcher_install.py +0 -0
  70. {meshcode-2.11.112 → meshcode-2.11.114rc1}/meshcode/meshcode_mcp/__init__.py +0 -0
  71. {meshcode-2.11.112 → meshcode-2.11.114rc1}/meshcode/meshcode_mcp/__main__.py +0 -0
  72. {meshcode-2.11.112 → meshcode-2.11.114rc1}/meshcode/meshcode_mcp/backend.py +0 -0
  73. {meshcode-2.11.112 → meshcode-2.11.114rc1}/meshcode/meshcode_mcp/realtime.py +0 -0
  74. {meshcode-2.11.112 → meshcode-2.11.114rc1}/meshcode/meshcode_mcp/server.py +0 -0
  75. {meshcode-2.11.112 → meshcode-2.11.114rc1}/meshcode/meshcode_mcp/sleep_signals.py +0 -0
  76. {meshcode-2.11.112 → meshcode-2.11.114rc1}/meshcode/meshcode_mcp/test_backend.py +0 -0
  77. {meshcode-2.11.112 → meshcode-2.11.114rc1}/meshcode/meshcode_mcp/test_boot_timing.py +0 -0
  78. {meshcode-2.11.112 → meshcode-2.11.114rc1}/meshcode/meshcode_mcp/test_install_guard.py +0 -0
  79. {meshcode-2.11.112 → meshcode-2.11.114rc1}/meshcode/meshcode_mcp/test_prefs_claude_version.py +0 -0
  80. {meshcode-2.11.112 → meshcode-2.11.114rc1}/meshcode/meshcode_mcp/test_realtime.py +0 -0
  81. {meshcode-2.11.112 → meshcode-2.11.114rc1}/meshcode/meshcode_mcp/test_server_wrapper.py +0 -0
  82. {meshcode-2.11.112 → meshcode-2.11.114rc1}/meshcode/preferences.py +0 -0
  83. {meshcode-2.11.112 → meshcode-2.11.114rc1}/meshcode/protocol_handler.py +0 -0
  84. {meshcode-2.11.112 → meshcode-2.11.114rc1}/meshcode/protocol_v2.py +0 -0
  85. {meshcode-2.11.112 → meshcode-2.11.114rc1}/meshcode/quickstart.py +0 -0
  86. {meshcode-2.11.112 → meshcode-2.11.114rc1}/meshcode/rpc_allowlist.py +0 -0
  87. {meshcode-2.11.112 → meshcode-2.11.114rc1}/meshcode/scripts/check_secrets.py +0 -0
  88. {meshcode-2.11.112 → meshcode-2.11.114rc1}/meshcode/scripts/race_rate_harness.py +0 -0
  89. {meshcode-2.11.112 → meshcode-2.11.114rc1}/meshcode/secrets.py +0 -0
  90. {meshcode-2.11.112 → meshcode-2.11.114rc1}/meshcode/self_update.py +0 -0
  91. {meshcode-2.11.112 → meshcode-2.11.114rc1}/meshcode/setup_clients.py +0 -0
  92. {meshcode-2.11.112 → meshcode-2.11.114rc1}/meshcode/supervisor.py +0 -0
  93. /meshcode-2.11.112/meshcode/up.py → /meshcode-2.11.114rc1/meshcode/up 2.py +0 -0
  94. {meshcode-2.11.112 → meshcode-2.11.114rc1}/meshcode/upload.py +0 -0
  95. {meshcode-2.11.112 → meshcode-2.11.114rc1}/meshcode.egg-info/dependency_links.txt +0 -0
  96. {meshcode-2.11.112 → meshcode-2.11.114rc1}/meshcode.egg-info/entry_points.txt +0 -0
  97. {meshcode-2.11.112 → meshcode-2.11.114rc1}/meshcode.egg-info/requires.txt +0 -0
  98. {meshcode-2.11.112 → meshcode-2.11.114rc1}/meshcode.egg-info/top_level.txt +0 -0
  99. {meshcode-2.11.112 → meshcode-2.11.114rc1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meshcode
3
- Version: 2.11.112
3
+ Version: 2.11.114rc1
4
4
  Summary: Real-time communication between AI agents — Supabase-backed CLI
5
5
  Author-email: MeshCode <hello@meshcode.io>
6
6
  License: MIT
@@ -1,5 +1,5 @@
1
1
  """MeshCode — Real-time communication between AI agents."""
2
- __version__ = "2.11.112"
2
+ __version__ = "2.11.114rc1"
3
3
 
4
4
  # Exception hierarchy — eagerly imported (lightweight, no deps)
5
5
  from meshcode.exceptions import ( # noqa: F401
@@ -0,0 +1,296 @@
1
+ """Session-handoff hook templates — single source of truth for the
2
+ PreCompact (write) + SessionStart (read) hooks.
3
+
4
+ CTX-CLOSE-RELAUNCH (task bcd157a9, backend2): when a commander/agent session
5
+ gets recycled at high context — either by Claude Code's in-place autocompact
6
+ or by a clean close+relaunch — the IN-FLIGHT working context (which task I was
7
+ mid-executing, what I just edited/decided this session) is lost. meshcode_boot
8
+ restores mesh state (tasks, inbox, memory) authoritatively, but NOT the
9
+ conversational thread. These two hooks bridge that gap:
10
+
11
+ * PreCompact -> session_handoff_write.py: dump a compact snapshot of the
12
+ recent transcript tail to .claude/handoff.json BEFORE the context is
13
+ compacted/the session exits. Pure local file I/O — no creds, no network,
14
+ no MCP — so it is safe to run in any hook subprocess on any platform.
15
+ * SessionStart -> session_handoff_read.py: on the next (fresh) session,
16
+ inject that snapshot back as additionalContext so the agent resumes the
17
+ thread, then archive it so it is injected exactly once.
18
+
19
+ Stored as string constants (same pattern as _stop_hook_template.py) so
20
+ `meshcode patch-hooks` can backfill existing workspaces without a full
21
+ re-scaffold. The recycle TRIGGER itself (mc_request_recycle / wait-loop
22
+ must_exit=recycle / hostd context branch) is owned by the daemon/server side
23
+ and is intentionally NOT in these scripts — handoff preservation is decoupled
24
+ from whatever causes the restart, so it works for autocompact, clean recycle,
25
+ crash-respawn, or a manual relaunch alike.
26
+ """
27
+
28
+ # Shared constants kept in sync between the two bodies.
29
+ _HANDOFF_FILENAME = "handoff.json"
30
+ _HANDOFF_MAX_TURNS = 24 # transcript tail entries to preserve
31
+ _HANDOFF_MAX_CHARS_PER_TURN = 1200
32
+ _HANDOFF_MAX_AGE_S = 24 * 3600 # ignore stale handoffs on read
33
+
34
+ HANDOFF_WRITE_BODY = '''#!/usr/bin/env python3
35
+ """PreCompact hook: snapshot the recent transcript tail to .claude/handoff.json
36
+ so a fresh post-recycle session can resume the in-flight thread.
37
+
38
+ CTX-CLOSE-RELAUNCH (task bcd157a9). Pure local file I/O — never touches the
39
+ network, never imports meshcode, never blocks. Any failure is swallowed and the
40
+ hook exits 0 so it can NEVER stall or break a Claude Code compaction/exit.
41
+
42
+ Claude Code passes a JSON event on stdin:
43
+ {"hook_event_name":"PreCompact","session_id":..,"transcript_path":..,
44
+ "trigger":"auto"|"manual", ...}
45
+ """
46
+ import json
47
+ import os
48
+ import sys
49
+ from pathlib import Path
50
+
51
+ MAX_TURNS = 24
52
+ MAX_CHARS_PER_TURN = 1200
53
+
54
+
55
+ def _project_dir() -> Path:
56
+ # Claude Code exports CLAUDE_PROJECT_DIR for hooks; fall back to cwd.
57
+ return Path(os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd())
58
+
59
+
60
+ def _text_from_message(msg) -> str:
61
+ """Flatten a transcript message's content into plain text + tool markers."""
62
+ if isinstance(msg, str):
63
+ return msg
64
+ if not isinstance(msg, dict):
65
+ return ""
66
+ content = msg.get("content")
67
+ if isinstance(content, str):
68
+ return content
69
+ parts = []
70
+ if isinstance(content, list):
71
+ for block in content:
72
+ if not isinstance(block, dict):
73
+ continue
74
+ btype = block.get("type")
75
+ if btype == "text" and block.get("text"):
76
+ parts.append(str(block["text"]))
77
+ elif btype == "tool_use":
78
+ parts.append(f"[tool_use:{block.get('name','?')}]")
79
+ elif btype == "tool_result":
80
+ parts.append("[tool_result]")
81
+ return " ".join(parts).strip()
82
+
83
+
84
+ def _extract_tail(transcript_path: str):
85
+ """Return up to MAX_TURNS (role, text) tuples from the end of the JSONL
86
+ transcript, skipping empty / meta-only entries."""
87
+ p = Path(transcript_path) if transcript_path else None
88
+ if not p or not p.exists():
89
+ return []
90
+ rows = []
91
+ try:
92
+ with p.open("r", encoding="utf-8", errors="replace") as fh:
93
+ for line in fh:
94
+ line = line.strip()
95
+ if not line:
96
+ continue
97
+ try:
98
+ obj = json.loads(line)
99
+ except (json.JSONDecodeError, ValueError):
100
+ continue
101
+ msg = obj.get("message") if isinstance(obj, dict) else None
102
+ role = (obj.get("type") or (msg or {}).get("role") or "").strip()
103
+ if role not in ("user", "assistant", "human"):
104
+ continue
105
+ text = _text_from_message(msg if msg is not None else obj)
106
+ if not text:
107
+ continue
108
+ if len(text) > MAX_CHARS_PER_TURN:
109
+ text = text[:MAX_CHARS_PER_TURN] + " …[truncated]"
110
+ rows.append(("user" if role in ("user", "human") else "assistant", text))
111
+ except OSError:
112
+ return []
113
+ return rows[-MAX_TURNS:]
114
+
115
+
116
+ def _request_recycle_if_marked(project_dir) -> None:
117
+ """CTX-CLOSE-RELAUNCH (task 400fc536): commander-tier sessions ask the
118
+ server to recycle (close+relaunch fresh) at the next task-edge, right after
119
+ the handoff is snapshotted. Gated by the scaffold-baked
120
+ .claude/meshcode_hook_ctx.json recycle_on_compact flag (commander-only v1).
121
+
122
+ Best-effort in every dimension: missing marker/flag, missing creds, import
123
+ failure, network error -> silently skip. handoff.json is already written, so
124
+ a context-recycle still relaunches WITH context; and the actual exit is the
125
+ server's call (mc_consume_recycle at a task boundary), never this hook.
126
+ """
127
+ try:
128
+ ctx_path = project_dir / ".claude" / "meshcode_hook_ctx.json"
129
+ if not ctx_path.exists():
130
+ return
131
+ if not json.loads(ctx_path.read_text(encoding="utf-8")).get("recycle_on_compact"):
132
+ return # non-commander -> in-place autocompact, no recycle
133
+ mcp = json.loads((project_dir / ".mcp.json").read_text(encoding="utf-8"))
134
+ env = (next(iter((mcp.get("mcpServers") or {}).values()), {}) or {}).get("env", {}) or {}
135
+ url = env.get("SUPABASE_URL"); key = env.get("SUPABASE_KEY")
136
+ pid = env.get("MESHCODE_PROJECT_ID"); agent = env.get("MESHCODE_AGENT")
137
+ if not (url and key and pid and agent):
138
+ return
139
+ api_key = os.environ.get("MESHCODE_API_KEY")
140
+ if not api_key:
141
+ try:
142
+ import importlib
143
+ api_key = importlib.import_module("meshcode.secrets").get_api_key(
144
+ profile=env.get("MESHCODE_KEYCHAIN_PROFILE") or "default")
145
+ except Exception:
146
+ api_key = None
147
+ if not api_key:
148
+ return
149
+ import urllib.request as _u
150
+ body = json.dumps({
151
+ "p_api_key": api_key, "p_project_id": pid,
152
+ "p_agent_name": agent, "p_allow_busy": True, # flag-now; exit deferred to wait-loop
153
+ }).encode("utf-8")
154
+ req = _u.Request(
155
+ url.rstrip("/") + "/rest/v1/rpc/mc_request_recycle",
156
+ data=body, method="POST",
157
+ headers={"apikey": key, "Authorization": "Bearer " + key,
158
+ "Content-Type": "application/json"})
159
+ _u.urlopen(req, timeout=5).read() # best-effort; ignore result per backend contract
160
+ except Exception as e: # noqa: BLE001 — never block compaction
161
+ sys.stderr.write(f"[session_handoff_write] recycle-request skipped: {e}\\n")
162
+
163
+
164
+ def main() -> int:
165
+ try:
166
+ raw = sys.stdin.read()
167
+ event = json.loads(raw) if raw.strip() else {}
168
+ except (json.JSONDecodeError, ValueError):
169
+ event = {}
170
+
171
+ tail = _extract_tail(event.get("transcript_path", ""))
172
+ handoff = {
173
+ "schema": 1,
174
+ "session_id": event.get("session_id"),
175
+ "trigger": event.get("trigger") or event.get("hook_event_name") or "precompact",
176
+ "agent": os.environ.get("MESHCODE_AGENT"),
177
+ "project": os.environ.get("MESHCODE_PROJECT"),
178
+ "turns": [{"role": r, "text": t} for (r, t) in tail],
179
+ }
180
+ try:
181
+ d = _project_dir() / ".claude"
182
+ d.mkdir(parents=True, exist_ok=True)
183
+ tmp = d / "handoff.json.tmp"
184
+ tmp.write_text(json.dumps(handoff, ensure_ascii=False, indent=2), encoding="utf-8")
185
+ tmp.replace(d / "handoff.json")
186
+ except OSError as e:
187
+ sys.stderr.write(f"[session_handoff_write] skipped: {e}\\n")
188
+ # CTX-CLOSE-RELAUNCH (task 400fc536): now that the thread is snapshotted,
189
+ # commander-tier sessions ask the server to recycle at the next task-edge.
190
+ _request_recycle_if_marked(_project_dir())
191
+ return 0
192
+
193
+
194
+ if __name__ == "__main__":
195
+ try:
196
+ sys.exit(main())
197
+ except Exception as e: # never break compaction
198
+ sys.stderr.write(f"[session_handoff_write] error: {e}\\n")
199
+ sys.exit(0)
200
+ '''
201
+
202
+ HANDOFF_READ_BODY = '''#!/usr/bin/env python3
203
+ """SessionStart hook: if a handoff snapshot exists, inject it as
204
+ additionalContext so a freshly-recycled session resumes the in-flight thread,
205
+ then archive it so it is injected exactly once.
206
+
207
+ CTX-CLOSE-RELAUNCH (task bcd157a9). Pure local file I/O — no network, no
208
+ meshcode import, never blocks. meshcode_boot remains authoritative for mesh
209
+ state; this only restores the conversational thread.
210
+
211
+ Claude Code passes a JSON event on stdin:
212
+ {"hook_event_name":"SessionStart","source":"startup"|"resume"|"compact",..}
213
+ and reads our additionalContext from this hook's stdout JSON:
214
+ {"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":..}}
215
+ """
216
+ import json
217
+ import os
218
+ import sys
219
+ import time
220
+ from pathlib import Path
221
+
222
+ MAX_AGE_S = 24 * 3600
223
+
224
+
225
+ def _project_dir() -> Path:
226
+ return Path(os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd())
227
+
228
+
229
+ def _emit(context: str) -> None:
230
+ sys.stdout.write(json.dumps({
231
+ "hookSpecificOutput": {
232
+ "hookEventName": "SessionStart",
233
+ "additionalContext": context,
234
+ }
235
+ }))
236
+
237
+
238
+ def _render(handoff: dict) -> str:
239
+ turns = handoff.get("turns") or []
240
+ if not turns:
241
+ return ""
242
+ lines = [
243
+ "## Recovered session handoff (CTX-CLOSE-RELAUNCH)",
244
+ "",
245
+ "Your previous session was recycled at high context. meshcode_boot has "
246
+ "the authoritative mesh state (tasks/inbox/memory); the thread tail "
247
+ "below is the in-flight context that boot does not restore. Use it to "
248
+ "resume what you were mid-doing, then run the normal boot loop.",
249
+ "",
250
+ f"_trigger: {handoff.get('trigger','?')} · turns preserved: {len(turns)}_",
251
+ "",
252
+ ]
253
+ for t in turns:
254
+ role = (t.get("role") or "?").upper()
255
+ text = (t.get("text") or "").strip()
256
+ if text:
257
+ lines.append(f"**{role}:** {text}")
258
+ lines.append("")
259
+ return "\\n".join(lines).strip()
260
+
261
+
262
+ def main() -> int:
263
+ path = _project_dir() / ".claude" / "handoff.json"
264
+ if not path.exists():
265
+ return 0
266
+ try:
267
+ handoff = json.loads(path.read_text(encoding="utf-8"))
268
+ except (json.JSONDecodeError, ValueError, OSError):
269
+ return 0
270
+
271
+ # Ignore stale snapshots (mtime-based; avoids resurrecting old threads).
272
+ try:
273
+ if (time.time() - path.stat().st_mtime) > MAX_AGE_S:
274
+ path.replace(path.with_suffix(".stale.json"))
275
+ return 0
276
+ except OSError:
277
+ pass
278
+
279
+ context = _render(handoff)
280
+ if context:
281
+ _emit(context)
282
+ # Archive so the handoff is injected exactly once.
283
+ try:
284
+ path.replace(path.with_suffix(".consumed.json"))
285
+ except OSError:
286
+ pass
287
+ return 0
288
+
289
+
290
+ if __name__ == "__main__":
291
+ try:
292
+ sys.exit(main())
293
+ except Exception as e:
294
+ sys.stderr.write(f"[session_handoff_read] error: {e}\\n")
295
+ sys.exit(0)
296
+ '''
@@ -0,0 +1,296 @@
1
+ """Session-handoff hook templates — single source of truth for the
2
+ PreCompact (write) + SessionStart (read) hooks.
3
+
4
+ CTX-CLOSE-RELAUNCH (task bcd157a9, backend2): when a commander/agent session
5
+ gets recycled at high context — either by Claude Code's in-place autocompact
6
+ or by a clean close+relaunch — the IN-FLIGHT working context (which task I was
7
+ mid-executing, what I just edited/decided this session) is lost. meshcode_boot
8
+ restores mesh state (tasks, inbox, memory) authoritatively, but NOT the
9
+ conversational thread. These two hooks bridge that gap:
10
+
11
+ * PreCompact -> session_handoff_write.py: dump a compact snapshot of the
12
+ recent transcript tail to .claude/handoff.json BEFORE the context is
13
+ compacted/the session exits. Pure local file I/O — no creds, no network,
14
+ no MCP — so it is safe to run in any hook subprocess on any platform.
15
+ * SessionStart -> session_handoff_read.py: on the next (fresh) session,
16
+ inject that snapshot back as additionalContext so the agent resumes the
17
+ thread, then archive it so it is injected exactly once.
18
+
19
+ Stored as string constants (same pattern as _stop_hook_template.py) so
20
+ `meshcode patch-hooks` can backfill existing workspaces without a full
21
+ re-scaffold. The recycle TRIGGER itself (mc_request_recycle / wait-loop
22
+ must_exit=recycle / hostd context branch) is owned by the daemon/server side
23
+ and is intentionally NOT in these scripts — handoff preservation is decoupled
24
+ from whatever causes the restart, so it works for autocompact, clean recycle,
25
+ crash-respawn, or a manual relaunch alike.
26
+ """
27
+
28
+ # Shared constants kept in sync between the two bodies.
29
+ _HANDOFF_FILENAME = "handoff.json"
30
+ _HANDOFF_MAX_TURNS = 24 # transcript tail entries to preserve
31
+ _HANDOFF_MAX_CHARS_PER_TURN = 1200
32
+ _HANDOFF_MAX_AGE_S = 24 * 3600 # ignore stale handoffs on read
33
+
34
+ HANDOFF_WRITE_BODY = '''#!/usr/bin/env python3
35
+ """PreCompact hook: snapshot the recent transcript tail to .claude/handoff.json
36
+ so a fresh post-recycle session can resume the in-flight thread.
37
+
38
+ CTX-CLOSE-RELAUNCH (task bcd157a9). Pure local file I/O — never touches the
39
+ network, never imports meshcode, never blocks. Any failure is swallowed and the
40
+ hook exits 0 so it can NEVER stall or break a Claude Code compaction/exit.
41
+
42
+ Claude Code passes a JSON event on stdin:
43
+ {"hook_event_name":"PreCompact","session_id":..,"transcript_path":..,
44
+ "trigger":"auto"|"manual", ...}
45
+ """
46
+ import json
47
+ import os
48
+ import sys
49
+ from pathlib import Path
50
+
51
+ MAX_TURNS = 24
52
+ MAX_CHARS_PER_TURN = 1200
53
+
54
+
55
+ def _project_dir() -> Path:
56
+ # Claude Code exports CLAUDE_PROJECT_DIR for hooks; fall back to cwd.
57
+ return Path(os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd())
58
+
59
+
60
+ def _text_from_message(msg) -> str:
61
+ """Flatten a transcript message's content into plain text + tool markers."""
62
+ if isinstance(msg, str):
63
+ return msg
64
+ if not isinstance(msg, dict):
65
+ return ""
66
+ content = msg.get("content")
67
+ if isinstance(content, str):
68
+ return content
69
+ parts = []
70
+ if isinstance(content, list):
71
+ for block in content:
72
+ if not isinstance(block, dict):
73
+ continue
74
+ btype = block.get("type")
75
+ if btype == "text" and block.get("text"):
76
+ parts.append(str(block["text"]))
77
+ elif btype == "tool_use":
78
+ parts.append(f"[tool_use:{block.get('name','?')}]")
79
+ elif btype == "tool_result":
80
+ parts.append("[tool_result]")
81
+ return " ".join(parts).strip()
82
+
83
+
84
+ def _extract_tail(transcript_path: str):
85
+ """Return up to MAX_TURNS (role, text) tuples from the end of the JSONL
86
+ transcript, skipping empty / meta-only entries."""
87
+ p = Path(transcript_path) if transcript_path else None
88
+ if not p or not p.exists():
89
+ return []
90
+ rows = []
91
+ try:
92
+ with p.open("r", encoding="utf-8", errors="replace") as fh:
93
+ for line in fh:
94
+ line = line.strip()
95
+ if not line:
96
+ continue
97
+ try:
98
+ obj = json.loads(line)
99
+ except (json.JSONDecodeError, ValueError):
100
+ continue
101
+ msg = obj.get("message") if isinstance(obj, dict) else None
102
+ role = (obj.get("type") or (msg or {}).get("role") or "").strip()
103
+ if role not in ("user", "assistant", "human"):
104
+ continue
105
+ text = _text_from_message(msg if msg is not None else obj)
106
+ if not text:
107
+ continue
108
+ if len(text) > MAX_CHARS_PER_TURN:
109
+ text = text[:MAX_CHARS_PER_TURN] + " …[truncated]"
110
+ rows.append(("user" if role in ("user", "human") else "assistant", text))
111
+ except OSError:
112
+ return []
113
+ return rows[-MAX_TURNS:]
114
+
115
+
116
+ def _request_recycle_if_marked(project_dir) -> None:
117
+ """CTX-CLOSE-RELAUNCH (task 400fc536): commander-tier sessions ask the
118
+ server to recycle (close+relaunch fresh) at the next task-edge, right after
119
+ the handoff is snapshotted. Gated by the scaffold-baked
120
+ .claude/meshcode_hook_ctx.json recycle_on_compact flag (commander-only v1).
121
+
122
+ Best-effort in every dimension: missing marker/flag, missing creds, import
123
+ failure, network error -> silently skip. handoff.json is already written, so
124
+ a context-recycle still relaunches WITH context; and the actual exit is the
125
+ server's call (mc_consume_recycle at a task boundary), never this hook.
126
+ """
127
+ try:
128
+ ctx_path = project_dir / ".claude" / "meshcode_hook_ctx.json"
129
+ if not ctx_path.exists():
130
+ return
131
+ if not json.loads(ctx_path.read_text(encoding="utf-8")).get("recycle_on_compact"):
132
+ return # non-commander -> in-place autocompact, no recycle
133
+ mcp = json.loads((project_dir / ".mcp.json").read_text(encoding="utf-8"))
134
+ env = (next(iter((mcp.get("mcpServers") or {}).values()), {}) or {}).get("env", {}) or {}
135
+ url = env.get("SUPABASE_URL"); key = env.get("SUPABASE_KEY")
136
+ pid = env.get("MESHCODE_PROJECT_ID"); agent = env.get("MESHCODE_AGENT")
137
+ if not (url and key and pid and agent):
138
+ return
139
+ api_key = os.environ.get("MESHCODE_API_KEY")
140
+ if not api_key:
141
+ try:
142
+ import importlib
143
+ api_key = importlib.import_module("meshcode.secrets").get_api_key(
144
+ profile=env.get("MESHCODE_KEYCHAIN_PROFILE") or "default")
145
+ except Exception:
146
+ api_key = None
147
+ if not api_key:
148
+ return
149
+ import urllib.request as _u
150
+ body = json.dumps({
151
+ "p_api_key": api_key, "p_project_id": pid,
152
+ "p_agent_name": agent, "p_allow_busy": True, # flag-now; exit deferred to wait-loop
153
+ }).encode("utf-8")
154
+ req = _u.Request(
155
+ url.rstrip("/") + "/rest/v1/rpc/mc_request_recycle",
156
+ data=body, method="POST",
157
+ headers={"apikey": key, "Authorization": "Bearer " + key,
158
+ "Content-Type": "application/json"})
159
+ _u.urlopen(req, timeout=5).read() # best-effort; ignore result per backend contract
160
+ except Exception as e: # noqa: BLE001 — never block compaction
161
+ sys.stderr.write(f"[session_handoff_write] recycle-request skipped: {e}\\n")
162
+
163
+
164
+ def main() -> int:
165
+ try:
166
+ raw = sys.stdin.read()
167
+ event = json.loads(raw) if raw.strip() else {}
168
+ except (json.JSONDecodeError, ValueError):
169
+ event = {}
170
+
171
+ tail = _extract_tail(event.get("transcript_path", ""))
172
+ handoff = {
173
+ "schema": 1,
174
+ "session_id": event.get("session_id"),
175
+ "trigger": event.get("trigger") or event.get("hook_event_name") or "precompact",
176
+ "agent": os.environ.get("MESHCODE_AGENT"),
177
+ "project": os.environ.get("MESHCODE_PROJECT"),
178
+ "turns": [{"role": r, "text": t} for (r, t) in tail],
179
+ }
180
+ try:
181
+ d = _project_dir() / ".claude"
182
+ d.mkdir(parents=True, exist_ok=True)
183
+ tmp = d / "handoff.json.tmp"
184
+ tmp.write_text(json.dumps(handoff, ensure_ascii=False, indent=2), encoding="utf-8")
185
+ tmp.replace(d / "handoff.json")
186
+ except OSError as e:
187
+ sys.stderr.write(f"[session_handoff_write] skipped: {e}\\n")
188
+ # CTX-CLOSE-RELAUNCH (task 400fc536): now that the thread is snapshotted,
189
+ # commander-tier sessions ask the server to recycle at the next task-edge.
190
+ _request_recycle_if_marked(_project_dir())
191
+ return 0
192
+
193
+
194
+ if __name__ == "__main__":
195
+ try:
196
+ sys.exit(main())
197
+ except Exception as e: # never break compaction
198
+ sys.stderr.write(f"[session_handoff_write] error: {e}\\n")
199
+ sys.exit(0)
200
+ '''
201
+
202
+ HANDOFF_READ_BODY = '''#!/usr/bin/env python3
203
+ """SessionStart hook: if a handoff snapshot exists, inject it as
204
+ additionalContext so a freshly-recycled session resumes the in-flight thread,
205
+ then archive it so it is injected exactly once.
206
+
207
+ CTX-CLOSE-RELAUNCH (task bcd157a9). Pure local file I/O — no network, no
208
+ meshcode import, never blocks. meshcode_boot remains authoritative for mesh
209
+ state; this only restores the conversational thread.
210
+
211
+ Claude Code passes a JSON event on stdin:
212
+ {"hook_event_name":"SessionStart","source":"startup"|"resume"|"compact",..}
213
+ and reads our additionalContext from this hook's stdout JSON:
214
+ {"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":..}}
215
+ """
216
+ import json
217
+ import os
218
+ import sys
219
+ import time
220
+ from pathlib import Path
221
+
222
+ MAX_AGE_S = 24 * 3600
223
+
224
+
225
+ def _project_dir() -> Path:
226
+ return Path(os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd())
227
+
228
+
229
+ def _emit(context: str) -> None:
230
+ sys.stdout.write(json.dumps({
231
+ "hookSpecificOutput": {
232
+ "hookEventName": "SessionStart",
233
+ "additionalContext": context,
234
+ }
235
+ }))
236
+
237
+
238
+ def _render(handoff: dict) -> str:
239
+ turns = handoff.get("turns") or []
240
+ if not turns:
241
+ return ""
242
+ lines = [
243
+ "## Recovered session handoff (CTX-CLOSE-RELAUNCH)",
244
+ "",
245
+ "Your previous session was recycled at high context. meshcode_boot has "
246
+ "the authoritative mesh state (tasks/inbox/memory); the thread tail "
247
+ "below is the in-flight context that boot does not restore. Use it to "
248
+ "resume what you were mid-doing, then run the normal boot loop.",
249
+ "",
250
+ f"_trigger: {handoff.get('trigger','?')} · turns preserved: {len(turns)}_",
251
+ "",
252
+ ]
253
+ for t in turns:
254
+ role = (t.get("role") or "?").upper()
255
+ text = (t.get("text") or "").strip()
256
+ if text:
257
+ lines.append(f"**{role}:** {text}")
258
+ lines.append("")
259
+ return "\\n".join(lines).strip()
260
+
261
+
262
+ def main() -> int:
263
+ path = _project_dir() / ".claude" / "handoff.json"
264
+ if not path.exists():
265
+ return 0
266
+ try:
267
+ handoff = json.loads(path.read_text(encoding="utf-8"))
268
+ except (json.JSONDecodeError, ValueError, OSError):
269
+ return 0
270
+
271
+ # Ignore stale snapshots (mtime-based; avoids resurrecting old threads).
272
+ try:
273
+ if (time.time() - path.stat().st_mtime) > MAX_AGE_S:
274
+ path.replace(path.with_suffix(".stale.json"))
275
+ return 0
276
+ except OSError:
277
+ pass
278
+
279
+ context = _render(handoff)
280
+ if context:
281
+ _emit(context)
282
+ # Archive so the handoff is injected exactly once.
283
+ try:
284
+ path.replace(path.with_suffix(".consumed.json"))
285
+ except OSError:
286
+ pass
287
+ return 0
288
+
289
+
290
+ if __name__ == "__main__":
291
+ try:
292
+ sys.exit(main())
293
+ except Exception as e:
294
+ sys.stderr.write(f"[session_handoff_read] error: {e}\\n")
295
+ sys.exit(0)
296
+ '''