@passioncode-ai/fabric-agent-adapter 0.39.1 → 0.40.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 (86) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/README.md +9 -3
  3. package/package.json +1 -1
  4. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  5. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
  6. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +15 -4
  7. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +64 -0
  8. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +15 -0
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/knowledge.example.json +13 -0
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +264 -0
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +93 -0
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +3 -2
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +128 -0
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +2 -0
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +10 -183
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +204 -0
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +286 -0
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +327 -0
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +47 -0
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +79 -0
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +840 -0
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +340 -0
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +42 -1237
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +1 -16
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-crypto.mjs +41 -0
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +13 -45
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +387 -0
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +8 -67
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +52 -0
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +231 -0
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +2 -2
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-jsvalue.mjs +67 -0
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +202 -0
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-logs.mjs +136 -0
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +335 -0
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +2 -12
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +69 -0
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs +164 -0
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +1 -32
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +122 -0
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +177 -2541
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +1 -8
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +400 -0
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-time.mjs +46 -0
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +458 -0
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +402 -0
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +399 -0
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +78 -0
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +64 -1398
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +116 -0
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +902 -0
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +457 -0
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +36 -38
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +289 -0
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +15 -180
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +586 -0
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +39 -400
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +137 -0
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +402 -0
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +1 -6
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +310 -0
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_kit.py +243 -0
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +308 -0
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_logs.py +202 -0
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +431 -0
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +21 -43
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +102 -0
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py +338 -0
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +31 -38
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +173 -0
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +298 -3450
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +8 -8
  73. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +412 -0
  74. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_time.py +120 -0
  75. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +697 -0
  76. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +1526 -0
  77. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
  78. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +3 -0
  79. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
  80. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +116 -1392
  81. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +270 -0
  82. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +508 -0
  83. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +265 -0
  84. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +155 -0
  85. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +335 -0
  86. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +1 -0
@@ -0,0 +1,508 @@
1
+ """working-with-fabric, part of fabric_session.py (FAA-65): where Fabric and the session's files are, whether a
2
+ project uses Fabric, whether Fabric launched this session, and the log and spool the hooks write. Standard library only."""
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ import subprocess
9
+ import sys
10
+ import time
11
+ from pathlib import Path
12
+ from typing import Any
13
+ from typing import Dict
14
+
15
+
16
+ HUB_PROTOCOL = "fabric-hub/0.1"
17
+
18
+
19
+ HOOK_FEATURE = "session-hooks/1"
20
+
21
+
22
+ BOARD_STATUS_TOOL = "com.status"
23
+
24
+
25
+ WORKER_DEADLINE_S = 10.0
26
+
27
+
28
+ GIT_TIMEOUT_S = 1.5
29
+
30
+
31
+ PS_TIMEOUT_S = 0.5
32
+
33
+
34
+ LOG_CAP_BYTES = 1_000_000
35
+
36
+
37
+ SPOOL_CAP = 50
38
+
39
+
40
+ LOCK_STALE_S = 30.0
41
+
42
+
43
+ #: A session folder untouched this long belongs to a session that will not spool again: its worker failed, or the
44
+ #: session ended without SessionEnd. Nothing retries it, so the next spooling hook removes it (FAA-50).
45
+ SESSION_STALE_S = 7 * 86400
46
+
47
+
48
+ READ_CAP = 64 * 1024
49
+
50
+
51
+ CONFIG_CAP = 32 * 1024 * 1024
52
+
53
+
54
+ SESSION_ID = re.compile(r"[A-Za-z0-9._:-]{1,128}")
55
+
56
+
57
+ LOOPBACK_ORIGIN = re.compile(r"http://127\.0\.0\.1:(\d{1,5})")
58
+
59
+
60
+ VERSION = "0.40.0"
61
+
62
+
63
+ HOSTS = {"claude": "Claude Code", "codex": "Codex", "kimi": "Kimi Code",
64
+ "hermes": "Hermes", "opencode": "OpenCode", "openclaw": "OpenClaw", "pi": "Pi"}
65
+
66
+
67
+ NATIVE_PLUGIN_HOSTS = frozenset(("hermes", "opencode", "openclaw", "pi"))
68
+
69
+
70
+ # A session Fabric launched. FABRIC_LAUNCHED_SESSION=1 is the marker Fabric is asked to set (need D6); the others are
71
+ # what Fabric already gives a launched session (fabric sessionBundle.ts): only their NAMES are looked at, never a value
72
+ # — two of them carry the session's credential.
73
+ LAUNCH_MARKER = "FABRIC_LAUNCHED_SESSION"
74
+
75
+
76
+ LAUNCH_SIGNALS = ("FABRIC_ACP_SESSION", "FABRIC_BRIDGE_URL", "KILO_CONFIG_CONTENT")
77
+
78
+
79
+ #: Files that make a folder a Fabric project: the provider manifest and the contract pin adapt_project.py writes, and
80
+ #: a fabric-package/1 folder's manifest.
81
+ FABRIC_MARKERS = ("fabric-agent.json", "fabric-contract.lock.json", "fabric.package.json")
82
+
83
+
84
+ UNREAD_LINE = "%d unread on the project board: com.list"
85
+
86
+
87
+ def _home(env: Optional[Dict[str, str]] = None) -> Path:
88
+ env = os.environ if env is None else env
89
+ return Path(env.get("HOME") or env.get("USERPROFILE") or str(Path.home()))
90
+
91
+
92
+ def fabric_root(platform: str = sys.platform, env: Optional[Dict[str, str]] = None) -> Path:
93
+ """Fabric's registry root, where hub.json lives (fabric `agentRegistry.ts#registryDirs`)."""
94
+ env = os.environ if env is None else env
95
+ home = _home(env)
96
+ if platform == "darwin":
97
+ return home / "Library/Application Support/ai.passioncode.fabric"
98
+ if platform == "win32":
99
+ return Path(env.get("LOCALAPPDATA") or str(home / "AppData" / "Local")) / "passioncode-fabric"
100
+ return Path(env.get("XDG_DATA_HOME") or str(home / ".local/share")) / "passioncode-fabric"
101
+
102
+
103
+ def state_root(platform: str = sys.platform, env: Optional[Dict[str, str]] = None) -> Path:
104
+ """The hooks' own folder: the log and the per-session spool. Never Fabric's folder."""
105
+ env = os.environ if env is None else env
106
+ home = _home(env)
107
+ if platform == "darwin":
108
+ return home / "Library/Caches/ai.passioncode.fabric-agent-adapter"
109
+ if platform == "win32":
110
+ return Path(env.get("LOCALAPPDATA") or str(home / "AppData" / "Local")) / "fabric-agent-adapter"
111
+ return Path(env.get("XDG_CACHE_HOME") or str(home / ".cache")) / "fabric-agent-adapter"
112
+
113
+
114
+ def fabric_bin_candidates(platform: str = sys.platform, env: Optional[Dict[str, str]] = None) -> List[Path]:
115
+ """Where `<Fabric>/bin/fabric-mcp` is on a default install (ADR-0134 amendment 2; electron-builder targets)."""
116
+ env = os.environ if env is None else env
117
+ home = _home(env)
118
+ if platform == "darwin":
119
+ return [Path("/Applications/Fabric.app/Contents/Resources/bin/fabric-mcp"),
120
+ home / "Applications/Fabric.app/Contents/Resources/bin/fabric-mcp"]
121
+ if platform == "win32":
122
+ local = Path(env.get("LOCALAPPDATA") or str(home / "AppData" / "Local"))
123
+ return [local / "Programs" / "Fabric" / "resources" / "bin" / "fabric-mcp.cmd"]
124
+ return [Path("/opt/Fabric/resources/bin/fabric-mcp")]
125
+
126
+
127
+ def find_fabric_bin(platform: str = sys.platform, env: Optional[Dict[str, str]] = None) -> Optional[Path]:
128
+ for candidate in fabric_bin_candidates(platform, env):
129
+ if candidate.is_file():
130
+ return candidate
131
+ return None
132
+
133
+
134
+ def read_small(path: Path, cap: int = READ_CAP) -> Optional[str]:
135
+ """A regular file's text, or None: no FIFO, device or link target is read, nothing past `cap`."""
136
+ try:
137
+ st = os.lstat(str(path))
138
+ except OSError:
139
+ return None
140
+ import stat as _stat
141
+ if not _stat.S_ISREG(st.st_mode) or st.st_size > cap:
142
+ return None
143
+ try:
144
+ with open(str(path), "r", encoding="utf-8") as handle:
145
+ return handle.read(cap)
146
+ except (OSError, UnicodeDecodeError):
147
+ return None
148
+
149
+
150
+ def pid_alive(pid: int) -> bool:
151
+ if pid <= 0:
152
+ return False
153
+ if sys.platform == "win32":
154
+ # os.kill(pid, 0) on Windows sends CTRL_C_EVENT: never use it to probe.
155
+ import ctypes
156
+ kernel = ctypes.windll.kernel32 # type: ignore[attr-defined]
157
+ handle = kernel.OpenProcess(0x1000, False, pid) # PROCESS_QUERY_LIMITED_INFORMATION
158
+ if not handle:
159
+ return False
160
+ try:
161
+ code = ctypes.c_ulong()
162
+ return bool(kernel.GetExitCodeProcess(handle, ctypes.byref(code))) and code.value == 259 # STILL_ACTIVE
163
+ finally:
164
+ kernel.CloseHandle(handle)
165
+ try:
166
+ os.kill(pid, 0)
167
+ except PermissionError:
168
+ return True
169
+ except OSError:
170
+ return False
171
+ return True
172
+
173
+
174
+ def locate_hub(root: Path, alive=pid_alive) -> Dict[str, Any]:
175
+ """hub.json read the way an agent must (fabric `hub.ts#locateHub`), minus the door token: never read here."""
176
+ hub_file = root / "hub.json"
177
+ if not os.path.lexists(str(hub_file)):
178
+ return {"state": "not-running", "reason": "there is no hub.json"}
179
+ text = read_small(hub_file)
180
+ if text is None:
181
+ return {"state": "not-a-hub", "reason": "hub.json is not a small regular file"}
182
+ try:
183
+ doc = json.loads(text)
184
+ except ValueError:
185
+ return {"state": "not-a-hub", "reason": "hub.json is not JSON"}
186
+ if not isinstance(doc, dict) or doc.get("protocol") != HUB_PROTOCOL:
187
+ return {"state": "not-a-hub", "reason": "hub.json does not speak %s" % HUB_PROTOCOL}
188
+ match = LOOPBACK_ORIGIN.fullmatch(doc.get("origin") if isinstance(doc.get("origin"), str) else "")
189
+ if not match or not 1024 <= int(match.group(1)) <= 65535 or doc.get("mcp") != "/mcp":
190
+ return {"state": "not-a-hub", "reason": "hub.json names another origin or MCP path"}
191
+ pid = doc.get("pid")
192
+ if not isinstance(pid, int) or isinstance(pid, bool) or pid <= 0:
193
+ return {"state": "not-a-hub", "reason": "hub.json names no process"}
194
+ if not alive(pid):
195
+ return {"state": "not-running", "reason": "hub.json names process %d, which is not running" % pid}
196
+ features = doc.get("features")
197
+ features = [f for f in features if isinstance(f, str)] if isinstance(features, list) else []
198
+ return {"state": "running", "pid": pid, "features": features}
199
+
200
+
201
+ def _ancestors(cwd: Path) -> List[Path]:
202
+ try:
203
+ cwd = cwd.resolve()
204
+ except OSError:
205
+ pass
206
+ return [cwd] + list(cwd.parents)
207
+
208
+
209
+ def _stdio_entry(entry: Any) -> Optional[Dict[str, Any]]:
210
+ if not isinstance(entry, dict) or not isinstance(entry.get("command"), str):
211
+ return None
212
+ if entry.get("type") not in (None, "stdio"):
213
+ return None
214
+ args = entry.get("args") if isinstance(entry.get("args"), list) else []
215
+ command = entry["command"]
216
+ name = re.split(r"[\\/]", command)[-1].lower()
217
+ return {"command": command, "args": [str(a) for a in args],
218
+ "fabricMcp": name in ("fabric-mcp", "fabric-mcp.cmd")}
219
+
220
+
221
+ def claude_registration(cwd: Path, env: Optional[Dict[str, str]] = None) -> Optional[Dict[str, Any]]:
222
+ """Claude Code's MCP server `fabric` for this folder: local, then project (.mcp.json), then user scope.
223
+ Only command and args are returned; an entry's env is never read out."""
224
+ home = _home(env)
225
+ config: Dict[str, Any] = {}
226
+ text = read_small(home / ".claude.json", CONFIG_CAP)
227
+ if text:
228
+ try:
229
+ loaded = json.loads(text)
230
+ config = loaded if isinstance(loaded, dict) else {}
231
+ except ValueError:
232
+ config = {}
233
+ projects = config.get("projects") if isinstance(config.get("projects"), dict) else {}
234
+ for folder in _ancestors(cwd):
235
+ servers = (projects.get(str(folder)) or {}).get("mcpServers") if isinstance(projects.get(str(folder)), dict) else None
236
+ found = _stdio_entry((servers or {}).get("fabric")) if isinstance(servers, dict) else None
237
+ if found:
238
+ return dict(found, scope="local")
239
+ for folder in _ancestors(cwd):
240
+ text = read_small(folder / ".mcp.json")
241
+ if text:
242
+ try:
243
+ servers = json.loads(text).get("mcpServers")
244
+ except (ValueError, AttributeError):
245
+ servers = None
246
+ found = _stdio_entry(servers.get("fabric")) if isinstance(servers, dict) else None
247
+ if found:
248
+ return dict(found, scope="project")
249
+ servers = config.get("mcpServers")
250
+ found = _stdio_entry(servers.get("fabric")) if isinstance(servers, dict) else None
251
+ return dict(found, scope="user") if found else None
252
+
253
+
254
+ def codex_registered(env: Optional[Dict[str, str]] = None) -> bool:
255
+ env = os.environ if env is None else env
256
+ codex_home = Path(env.get("CODEX_HOME") or str(_home(env) / ".codex"))
257
+ text = read_small(codex_home / "config.toml", CONFIG_CAP) or ""
258
+ return re.search(r'^\s*\[\s*"?mcp_servers"?\s*\.\s*"?fabric"?\s*\]\s*$', text, re.MULTILINE) is not None
259
+
260
+
261
+ def kimi_registered(env: Optional[Dict[str, str]] = None) -> bool:
262
+ """Kimi Code keeps its MCP servers in ~/.kimi-code/mcp.json (`mcpServers`, Claude's shape)."""
263
+ text = read_small(_home(env) / ".kimi-code" / "mcp.json", CONFIG_CAP)
264
+ try:
265
+ servers = json.loads(text).get("mcpServers") if text else None
266
+ except (ValueError, AttributeError):
267
+ return False
268
+ return isinstance(servers, dict) and _stdio_entry(servers.get("fabric")) is not None
269
+
270
+
271
+ def host_registered(host: str, cwd: Path, env: Optional[Dict[str, str]] = None) -> bool:
272
+ if host == "claude":
273
+ return claude_registration(cwd, env) is not None
274
+ if host == "codex":
275
+ return codex_registered(env)
276
+ if host == "kimi":
277
+ return kimi_registered(env)
278
+ return False
279
+
280
+
281
+ def services_dir(platform: str = sys.platform, env: Optional[Dict[str, str]] = None) -> Path:
282
+ """Where fabric-service/0.1 descriptors live: FABRIC_SERVICES_DIR, else Fabric's folder per OS + /services."""
283
+ env = os.environ if env is None else env
284
+ if env.get("FABRIC_SERVICES_DIR"):
285
+ return Path(env["FABRIC_SERVICES_DIR"])
286
+ return fabric_root(platform, env) / "services"
287
+
288
+
289
+ def checkout_root(cwd: Path, env: Optional[Dict[str, str]] = None) -> Optional[Path]:
290
+ """The nearest folder at or above cwd with a `.git` entry (a worktree's `.git` is a file), never past HOME; None
291
+ outside a checkout. No subprocess."""
292
+ home = _home(env)
293
+ for folder in _ancestors(cwd):
294
+ if os.path.lexists(str(folder / ".git")):
295
+ return folder
296
+ if folder == home:
297
+ break
298
+ return None
299
+
300
+
301
+ def _repository_key(url: str) -> str:
302
+ """One spelling of a remote: scheme, user, `.git` and case dropped; `git@host:a/b` is `host/a/b`."""
303
+ url = url.strip().lower()
304
+ url = re.sub(r"^git@([^:]+):", r"\1/", url)
305
+ url = re.sub(r"^[a-z+]+://", "", url)
306
+ url = re.sub(r"^[^@/]+@", "", url)
307
+ return re.sub(r"\.git$", "", url).rstrip("/")
308
+
309
+
310
+ def _origin(root: Path) -> str:
311
+ try:
312
+ done = subprocess.run(["git", "--no-optional-locks", "-C", str(root), "config", "--get", "remote.origin.url"],
313
+ stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
314
+ timeout=GIT_TIMEOUT_S, check=False)
315
+ except (OSError, subprocess.SubprocessError):
316
+ return ""
317
+ return done.stdout.decode("utf-8", "replace").strip() if done.returncode == 0 else ""
318
+
319
+
320
+ def owns_a_service(root: Path, env: Optional[Dict[str, str]] = None, platform: str = sys.platform) -> bool:
321
+ """Whether a service descriptor names this checkout's origin as its `source.repository`. Reads each descriptor's
322
+ `source` only; never its auth block or a token file."""
323
+ origin = _origin(root)
324
+ if not origin:
325
+ return False
326
+ directory = services_dir(platform, env)
327
+ try:
328
+ names = sorted(p for p in os.listdir(str(directory)) if p.endswith(".json") and not p.startswith("."))
329
+ except OSError:
330
+ return False
331
+ key = _repository_key(origin)
332
+ for name in names:
333
+ try:
334
+ doc = json.loads(read_small(directory / name) or "null")
335
+ except ValueError:
336
+ continue
337
+ source = doc.get("source") if isinstance(doc, dict) else None
338
+ repository = source.get("repository") if isinstance(source, dict) else None
339
+ if isinstance(repository, str) and repository and _repository_key(repository) == key:
340
+ return True
341
+ return False
342
+
343
+
344
+ def project_uses_fabric(cwd: Path, env: Optional[Dict[str, str]] = None,
345
+ registration: Optional[Dict[str, Any]] = None, platform: str = sys.platform) -> Optional[str]:
346
+ """Which sign says the project at cwd works with Fabric, or None. Cheapest first: `registration` when Claude Code
347
+ has `fabric` at local or project scope (bound to this folder; user scope says nothing about it); a FABRIC_MARKERS
348
+ file from cwd up to its checkout's root (cwd alone outside a checkout); `service` when a descriptor names the
349
+ checkout's origin."""
350
+ if registration and registration.get("scope") in ("local", "project"):
351
+ return "registration"
352
+ root = checkout_root(cwd, env)
353
+ folders = _ancestors(cwd)
354
+ folders = folders[:folders.index(root) + 1] if root in folders else folders[:1]
355
+ import stat as _stat
356
+ for folder in folders:
357
+ for name in FABRIC_MARKERS:
358
+ try:
359
+ if _stat.S_ISREG(os.lstat(str(folder / name)).st_mode):
360
+ return name
361
+ except OSError:
362
+ continue
363
+ if root is not None and owns_a_service(root, env, platform):
364
+ return "service"
365
+ return None
366
+
367
+
368
+ def fabric_launched(env: Optional[Dict[str, str]] = None, platform: str = sys.platform) -> Optional[str]:
369
+ """The name of the signal that says Fabric launched this session, or None. Cheap: the environment only."""
370
+ env = os.environ if env is None else env
371
+ marker = env.get(LAUNCH_MARKER)
372
+ if marker and marker != "0":
373
+ return LAUNCH_MARKER
374
+ for name in LAUNCH_SIGNALS:
375
+ if name in env: # the name only: never the value
376
+ return name
377
+ codex_home = env.get("CODEX_HOME")
378
+ if codex_home:
379
+ sessions = os.path.normpath(str(fabric_root(platform, env) / "sessions")) + os.sep
380
+ if os.path.normpath(codex_home).startswith(sessions):
381
+ return "CODEX_HOME"
382
+ return None
383
+
384
+
385
+ def _process(pid: int) -> Tuple[Optional[str], int]:
386
+ """A process's command line and its parent's pid, or (None, 0). POSIX only; bounded."""
387
+ try:
388
+ if sys.platform.startswith("linux"):
389
+ with open("/proc/%d/cmdline" % pid, "rb") as handle:
390
+ command = handle.read(READ_CAP).replace(b"\0", b" ").decode("utf-8", "replace")
391
+ with open("/proc/%d/stat" % pid, "r", encoding="utf-8", errors="replace") as handle:
392
+ parent = int(handle.read(4096).rsplit(")", 1)[1].split()[1])
393
+ return command, parent
394
+ if sys.platform == "win32":
395
+ return None, 0
396
+ done = subprocess.run(["ps", "-ww", "-o", "ppid=", "-o", "command=", "-p", str(pid)], stdin=subprocess.DEVNULL,
397
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=PS_TIMEOUT_S, check=False)
398
+ parent_text, _, command = done.stdout.decode("utf-8", "replace").strip().partition(" ")
399
+ return (command, int(parent_text)) if done.returncode == 0 and parent_text.isdigit() else (None, 0)
400
+ except (OSError, ValueError, IndexError, subprocess.SubprocessError):
401
+ return None, 0
402
+
403
+
404
+ def launched_by_ancestor(env: Optional[Dict[str, str]] = None, depth: int = 4) -> bool:
405
+ """A Claude Code Fabric launched runs with `--mcp-config <Fabric>/sessions/<id>/mcp.json` (sessionBundle.ts,
406
+ adapter mcp-config-flag) and no environment marker yet: look for that in this hook's few ancestors. Only the
407
+ path is matched; nothing read here is kept or logged."""
408
+ marker = os.path.normpath(str(fabric_root(env=env) / "sessions")) + os.sep
409
+ pid = os.getppid()
410
+ for _ in range(depth):
411
+ if pid <= 1:
412
+ return False
413
+ command, parent = _process(pid)
414
+ if command is None:
415
+ return False
416
+ if "--mcp-config" in command and marker in command:
417
+ return True
418
+ pid = parent
419
+ return False
420
+
421
+
422
+ def log(line: str, env: Optional[Dict[str, str]] = None) -> None:
423
+ """One structured line, 0600, rotated at 1 MB. Never raises."""
424
+ try:
425
+ root = state_root(env=env)
426
+ root.mkdir(parents=True, exist_ok=True)
427
+ path = root / "hooks.log"
428
+ if path.exists() and path.stat().st_size > LOG_CAP_BYTES:
429
+ os.replace(str(path), str(root / "hooks.log.1"))
430
+ fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
431
+ with os.fdopen(fd, "a", encoding="utf-8") as handle:
432
+ handle.write("%s %s\n" % (time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), line))
433
+ except Exception: # noqa: BLE001 — logging never breaks a hook
434
+ pass
435
+
436
+
437
+ def _write_private(path: Path, text: str) -> None:
438
+ tmp = path.with_name(".%s.%d.tmp" % (path.name, os.getpid()))
439
+ fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
440
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
441
+ handle.write(text)
442
+ os.replace(str(tmp), str(path))
443
+
444
+
445
+ def session_dir(session_id: str, env: Optional[Dict[str, str]] = None) -> Path:
446
+ return state_root(env=env) / "sessions" / session_id
447
+
448
+
449
+ def spool(directory: Path, entry: Dict[str, Any]) -> Path:
450
+ directory.mkdir(parents=True, exist_ok=True, mode=0o700)
451
+ path = directory / ("%020d-%s.json" % (time.time_ns(), entry["event"]))
452
+ _write_private(path, json.dumps(entry, sort_keys=True))
453
+ turns = sorted(directory.glob("*-turn.json"))
454
+ for old in turns[:max(0, len(turns) - SPOOL_CAP)]:
455
+ try:
456
+ old.unlink()
457
+ except OSError:
458
+ pass
459
+ return path
460
+
461
+
462
+ def prune_stale_sessions(env: Optional[Dict[str, str]] = None, now: Optional[float] = None,
463
+ keep: Optional[str] = None) -> int:
464
+ """Remove session folders whose newest file is older than SESSION_STALE_S (a running worker's lock is a fresh file,
465
+ so its folder is never stale); never the folder of the session `keep`. Returns how many were removed. Never
466
+ raises."""
467
+ now = time.time() if now is None else now
468
+ removed = 0
469
+ try:
470
+ folders = list((state_root(env=env) / "sessions").iterdir())
471
+ except OSError:
472
+ return 0
473
+ for folder in folders:
474
+ try:
475
+ if folder.name == keep or not SESSION_ID.fullmatch(folder.name) or not folder.is_dir():
476
+ continue
477
+ newest = max([folder.stat().st_mtime] + [f.stat().st_mtime for f in folder.iterdir()])
478
+ if now - newest > SESSION_STALE_S:
479
+ _remove_tree(folder)
480
+ removed += 1
481
+ except OSError:
482
+ continue
483
+ return removed
484
+
485
+
486
+ def lock_held(directory: Path) -> bool:
487
+ lock = directory / "worker.lock"
488
+ try:
489
+ return time.time() - lock.stat().st_mtime < LOCK_STALE_S
490
+ except OSError:
491
+ return False
492
+
493
+
494
+ def spawn_worker(directory: Path) -> None:
495
+ """Start `deliver` detached: the hook returns at once and the worker outlives the session if it must."""
496
+ argv = [sys.executable, "-S", str(Path(__file__).resolve().with_name("fabric_session.py")), "deliver", str(directory)] # the entry point, not this module
497
+ options: Dict[str, Any] = {"stdin": subprocess.DEVNULL, "stdout": subprocess.DEVNULL,
498
+ "stderr": subprocess.DEVNULL, "close_fds": True}
499
+ if sys.platform == "win32":
500
+ options["creationflags"] = 0x00000008 | 0x00000200 # DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP
501
+ else:
502
+ options["start_new_session"] = True
503
+ subprocess.Popen(argv, **options) # noqa: S603 — our own script, argv holds no secret
504
+
505
+
506
+ def _remove_tree(directory: Path) -> None:
507
+ import shutil
508
+ shutil.rmtree(str(directory), ignore_errors=True)