@passioncode-ai/fabric-agent-adapter 0.78.0 → 0.80.2

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 (105) hide show
  1. package/CHANGELOG.md +256 -0
  2. package/README.md +14 -7
  3. package/bin/fabric-agent-adapter.js +26 -4
  4. package/package.json +1 -1
  5. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  6. package/plugins/fabric-agent-adapter/hooks/hooks.json +1 -1
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +6 -4
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/agents-database.md +4 -4
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +42 -7
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +13 -3
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +3 -3
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +12 -4
  13. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +23 -8
  14. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +6 -2
  15. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +2 -1
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +4 -2
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +6 -3
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +34 -8
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/README.md +67 -0
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/dashboard_service.py +252 -0
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/install.mjs +168 -0
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/install.py +179 -0
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/README.md +57 -0
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/door.mjs +181 -0
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/door.py +237 -0
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/agents-database.md +4 -4
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +11 -6
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +2 -2
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +1 -1
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +42 -7
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +6 -2
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +12 -1
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +26 -3
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +4 -2
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +14 -4
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +6 -2
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +44 -3
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +12 -4
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +7 -5
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +10 -5
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +1 -0
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +5 -0
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +2 -0
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +15 -6
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +3 -2
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-context.mjs +37 -5
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +30 -2
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +48 -5
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +9 -3
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +16 -2
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-load.mjs +344 -0
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +54 -20
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +4 -0
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +8 -2
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +4 -1
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +8 -0
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +55 -14
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +8 -1
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +6 -0
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +3 -0
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +16 -7
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +4 -2
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +97 -3
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_context.py +50 -8
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +22 -4
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +49 -3
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +9 -2
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +15 -2
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +2 -2
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_load.py +358 -0
  73. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +41 -1
  74. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +6 -0
  75. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +17 -7
  76. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +6 -2
  77. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +24 -0
  78. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +110 -16
  79. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +12 -1
  80. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +543 -37
  81. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +4 -1
  82. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +12 -5
  83. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-database.md +4 -4
  84. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +42 -7
  85. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +6 -3
  86. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +13 -3
  87. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/resilience-and-autonomy.md +13 -4
  88. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/where-agents-run.md +32 -0
  89. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/working-rules.md +54 -0
  90. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +12 -4
  91. package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
  92. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +27 -26
  93. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/agents-database.md +4 -4
  94. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/cleanup.md +30 -0
  95. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +59 -38
  96. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +52 -26
  97. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/remote-sessions.md +75 -0
  98. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +28 -13
  99. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/task-context.md +58 -0
  100. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/waits.md +36 -0
  101. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +14 -0
  102. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +169 -46
  103. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +146 -19
  104. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +78 -3
  105. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_paths.py +9 -2
@@ -58,7 +58,7 @@ SESSION_ID = re.compile(r"[A-Za-z0-9._:-]{1,128}")
58
58
  LOOPBACK_ORIGIN = re.compile(r"http://127\.0\.0\.1:(\d{1,5})")
59
59
 
60
60
 
61
- VERSION = "0.78.0"
61
+ VERSION = "0.80.2"
62
62
 
63
63
 
64
64
  HOSTS = {"claude": "Claude Code", "codex": "Codex", "kimi": "Kimi Code",
@@ -98,7 +98,7 @@ UNREAD_LINE = ("%d unread on the project board: com.list; answer what waits on y
98
98
  #: them once: at most MAX_NUDGES lines, each one line of at most MAX_NUDGE_CHARS characters.
99
99
  NUDGES_FILE = "nudges.json"
100
100
  #: Each nudge's line names its source, so the agent sees Fabric's words, data to act on through its tools, never the
101
- #: person's request. Fabric 0.3.14 starts every `says` with "Fabric:" (board e94ec9c0, release/0.3.14 b63dedb6); a
101
+ #: person's request. Fabric 0.3.14 starts every `says` with "Fabric:" (board e94ec9c0; tag v0.3.14 7e43dc03, FAA-143); a
102
102
  #: line that does not gets this tag.
103
103
  NUDGE_PREFIX = "Fabric reminder: "
104
104
 
@@ -280,7 +280,9 @@ def read_small(path: Path, cap: int = READ_CAP) -> Optional[str]:
280
280
  return None
281
281
 
282
282
 
283
- def pid_alive(pid: int) -> bool:
283
+ def pid_alive(pid: int) -> Optional[bool]:
284
+ """True: a process of this OS account runs under `pid`; False: none does. None (falsy): a process of another
285
+ account does (EPERM), as when the id hub.json names was reused: not verified, never called running (FAA-254)."""
284
286
  if pid <= 0:
285
287
  return False
286
288
  if sys.platform == "win32":
@@ -298,7 +300,7 @@ def pid_alive(pid: int) -> bool:
298
300
  try:
299
301
  os.kill(pid, 0)
300
302
  except PermissionError:
301
- return True
303
+ return None
302
304
  except OSError:
303
305
  return False
304
306
  return True
@@ -324,7 +326,11 @@ def locate_hub(root: Path, alive=pid_alive) -> Dict[str, Any]:
324
326
  pid = doc.get("pid")
325
327
  if not isinstance(pid, int) or isinstance(pid, bool) or pid <= 0:
326
328
  return {"state": "not-a-hub", "reason": "hub.json names no process"}
327
- if not alive(pid):
329
+ live = alive(pid)
330
+ if live is None: # FAA-254: another account's process under that id, so not this account's Fabric
331
+ return {"state": "not-a-hub", "reason": "hub.json names process %d, which runs under another OS account: "
332
+ "not verified" % pid}
333
+ if not live:
328
334
  return {"state": "not-running", "reason": "hub.json names process %d, which is not running" % pid}
329
335
  features = doc.get("features")
330
336
  features = [f for f in features if isinstance(f, str)] if isinstance(features, list) else []
@@ -351,13 +357,47 @@ def _stdio_entry(entry: Any) -> Optional[Dict[str, Any]]:
351
357
  "fabricMcp": name in ("fabric-mcp", "fabric-mcp.cmd")}
352
358
 
353
359
 
360
+ #: Claude Code's own variable for its folder, and Fabric Switchboard's for the person's folder a Switchboard home is
361
+ #: built from (0.7.14 and later); a home of an earlier Switchboard lies in its data folder (FAA-218).
362
+ CLAUDE_CONFIG_ENV = "CLAUDE_CONFIG_DIR"
363
+ SWITCHBOARD_SOURCE_ENV = "SWITCHBOARD_CLAUDE_SOURCE_DIR"
364
+ SWITCHBOARD_DATA = "ai.passioncode.fabric-switchboard"
365
+
366
+
367
+ def claude_folders(env: Optional[Dict[str, str]] = None) -> Dict[str, Optional[Path]]:
368
+ """The launcher's `claudeFolders` (passioncode lib/launcher.js, PC-129; FAA-218): `read` is where the session's
369
+ Claude Code files are, CLAUDE_CONFIG_DIR when set, else ~/.claude; `write` is where settings, plugins and user MCP
370
+ servers are written, None for Claude Code's default (~/.claude with ~/.claude.json). In a Switchboard home `write`
371
+ is SWITCHBOARD_CLAUDE_SOURCE_DIR when it is absolute, ~/.claude's default for a home of an earlier Switchboard."""
372
+ env = os.environ if env is None else env
373
+ home = _home(env)
374
+ value = env.get(CLAUDE_CONFIG_ENV) or ""
375
+ config = Path(os.path.abspath(value)) if value else None
376
+ named = env.get(SWITCHBOARD_SOURCE_ENV) or ""
377
+ source: Optional[Path] = None
378
+ if config is not None:
379
+ if named and os.path.isabs(named):
380
+ source = Path(os.path.abspath(named))
381
+ elif SWITCHBOARD_DATA in re.split(r"[\\/]", str(config)):
382
+ source = home / ".claude"
383
+ write = (None if source == home / ".claude" else source) if source is not None else config
384
+ return {"read": config or home / ".claude", "write": write}
385
+
386
+
387
+ def claude_json(env: Optional[Dict[str, str]] = None) -> Path:
388
+ """The `.claude.json` Claude Code reads in this environment: inside CLAUDE_CONFIG_DIR when it is set (Claude Code
389
+ 2.1.299 reads and `claude mcp add` writes it there, FAA-234), else ~/.claude.json. In a Switchboard home that is the
390
+ home's own, the servers Switchboard carried into it."""
391
+ env = os.environ if env is None else env
392
+ return (claude_folders(env)["read"] if env.get(CLAUDE_CONFIG_ENV) else _home(env)) / ".claude.json"
393
+
394
+
354
395
  def claude_registration(cwd: Path, env: Optional[Dict[str, str]] = None) -> Optional[Dict[str, Any]]:
355
396
  """Claude Code's MCP server `fabric` for this folder: local, then project (.mcp.json), then user scope.
356
397
  Only command and args are returned, with the folder a local or project entry was found in; an entry's env is never
357
398
  read out."""
358
- home = _home(env)
359
399
  config: Dict[str, Any] = {}
360
- text = read_small(home / ".claude.json", CONFIG_CAP)
400
+ text = read_small(claude_json(env), CONFIG_CAP)
361
401
  if text:
362
402
  try:
363
403
  loaded = json.loads(text)
@@ -365,9 +405,18 @@ def claude_registration(cwd: Path, env: Optional[Dict[str, str]] = None) -> Opti
365
405
  except ValueError:
366
406
  config = {}
367
407
  projects = config.get("projects") if isinstance(config.get("projects"), dict) else {}
368
- for folder in _ancestors(cwd):
369
- servers = (projects.get(str(folder)) or {}).get("mcpServers") if isinstance(projects.get(str(folder)), dict) else None
370
- found = _stdio_entry((servers or {}).get("fabric")) if isinstance(servers, dict) else None
408
+ # FAA-235: Claude Code keys a local entry by the git root of the folder it was added in, or that folder outside a
409
+ # checkout, and applies it only there (Claude Code 2.1.299; docs/evidence/runs/2026-11-12-faa-235-local-scope-key.md):
410
+ # one key, never an ancestor's. A hook's cwd moves with the session (a submodule, a worktree under .claude/), while
411
+ # Claude Code keeps the entry of the folder the session started in, which it hands hooks as CLAUDE_PROJECT_DIR:
412
+ # that folder's key is read too (the 0.80.0 seam reading)
413
+ started = (env if env is not None else os.environ).get("CLAUDE_PROJECT_DIR") or ""
414
+ keys = [cwd] + ([Path(started)] if started else [])
415
+ for place in keys:
416
+ folder = checkout_root(place, env) or _ancestors(place)[0]
417
+ project = projects.get(str(folder))
418
+ servers = project.get("mcpServers") if isinstance(project, dict) else None
419
+ found = _stdio_entry(servers.get("fabric")) if isinstance(servers, dict) else None
371
420
  if found:
372
421
  return dict(found, scope="local", folder=str(folder))
373
422
  for folder in _ancestors(cwd):
@@ -385,21 +434,86 @@ def claude_registration(cwd: Path, env: Optional[Dict[str, str]] = None) -> Opti
385
434
  return dict(found, scope="user") if found else None
386
435
 
387
436
 
388
- def codex_registered(env: Optional[Dict[str, str]] = None) -> bool:
437
+ _CODEX_FABRIC = re.compile(r'^\s*\[\s*"?mcp_servers"?\s*\.\s*"?fabric"?\s*\]\s*$', re.MULTILINE)
438
+ _TOML_HEADER = re.compile(r"^\s*\[", re.MULTILINE)
439
+ #: one `command = "..."` line of a table (a basic or literal string), up to the line's end: a lookahead, not `$` (R1)
440
+ _TOML_COMMAND = re.compile(r"""^[ \t]*command[ \t]*=[ \t]*("(?:[^"\\\n]|\\.)*"|'[^'\n]*')[ \t]*(?:#[^\n]*)?(?=\r?\n|\Z)""",
441
+ re.MULTILINE)
442
+
443
+
444
+ def _codex_config(env: Optional[Dict[str, str]] = None) -> str:
389
445
  env = os.environ if env is None else env
390
446
  codex_home = Path(env.get("CODEX_HOME") or str(_home(env) / ".codex"))
391
- text = read_small(codex_home / "config.toml", CONFIG_CAP) or ""
392
- return re.search(r'^\s*\[\s*"?mcp_servers"?\s*\.\s*"?fabric"?\s*\]\s*$', text, re.MULTILINE) is not None
447
+ return read_small(codex_home / "config.toml", CONFIG_CAP) or ""
393
448
 
394
449
 
395
- def kimi_registered(env: Optional[Dict[str, str]] = None) -> bool:
396
- """Kimi Code keeps its MCP servers in ~/.kimi-code/mcp.json (`mcpServers`, Claude's shape)."""
450
+ def codex_registered(env: Optional[Dict[str, str]] = None) -> bool:
451
+ return _CODEX_FABRIC.search(_codex_config(env)) is not None
452
+
453
+
454
+ def codex_command(env: Optional[Dict[str, str]] = None) -> Optional[str]:
455
+ """The `command` of `[mcp_servers.fabric]` in Codex's config.toml (FAA-233), a basic or literal TOML string, or
456
+ None. Read with a pattern, not a TOML parser (Python 3.9 has none): the table runs to the next header."""
457
+ text = _codex_config(env)
458
+ header = _CODEX_FABRIC.search(text)
459
+ if header is None:
460
+ return None
461
+ rest = text[header.end():]
462
+ following = _TOML_HEADER.search(rest)
463
+ found = _TOML_COMMAND.search(rest[:following.start()] if following else rest)
464
+ if not found:
465
+ return None
466
+ value = found.group(1)
467
+ if value.startswith("'"):
468
+ return value[1:-1]
469
+ try:
470
+ decoded = json.loads(value)
471
+ except ValueError:
472
+ return None
473
+ return decoded if isinstance(decoded, str) else None
474
+
475
+
476
+ def kimi_registration(env: Optional[Dict[str, str]] = None) -> Optional[Dict[str, Any]]:
477
+ """Kimi Code keeps its MCP servers in ~/.kimi-code/mcp.json (`mcpServers`, Claude's shape): the `fabric` entry's
478
+ command and args, or None."""
397
479
  text = read_small(_home(env) / ".kimi-code" / "mcp.json", CONFIG_CAP)
398
480
  try:
399
481
  servers = json.loads(text).get("mcpServers") if text else None
400
- except (ValueError, AttributeError):
482
+ except (ValueError, AttributeError, RecursionError):
483
+ return None
484
+ return _stdio_entry(servers.get("fabric")) if isinstance(servers, dict) else None
485
+
486
+
487
+ def kimi_registered(env: Optional[Dict[str, str]] = None) -> bool:
488
+ return kimi_registration(env) is not None
489
+
490
+
491
+ def registered_command(host: str, cwd: Path, env: Optional[Dict[str, str]] = None) -> Optional[str]:
492
+ """The command the host runs as `fabric` (FAA-232, FAA-233): Claude Code's entry for this folder, Codex's
493
+ config.toml, Kimi Code's mcp.json; None when the host registers none, or registers it in a file read elsewhere."""
494
+ if host == "claude":
495
+ entry = claude_registration(cwd, env)
496
+ return entry["command"] if entry else None
497
+ if host == "codex":
498
+ return codex_command(env)
499
+ if host == "kimi":
500
+ entry = kimi_registration(env)
501
+ return entry["command"] if entry else None
502
+ return None
503
+
504
+
505
+ def foreign_command(command: Optional[str], platform: str = sys.platform, env: Optional[Dict[str, str]] = None,
506
+ lookup: bool = True) -> bool:
507
+ """Whether a registered command runs another user's copy (FAA-229): never one relative to a folder (`./`), and a
508
+ bare name only with `lookup` (a PATH lookup, which status makes and the session hooks do not)."""
509
+ if not command or re.split(r"[\\/]", command)[-1].lower() not in ("fabric-mcp", "fabric-mcp.cmd"):
510
+ return False
511
+ if command.startswith(("./", ".\\")): # Path() drops a leading ./, which would read as a PATH name
401
512
  return False
402
- return isinstance(servers, dict) and _stdio_entry(servers.get("fabric")) is not None
513
+ path = Path(command)
514
+ if not lookup and not path.is_absolute():
515
+ return False
516
+ return another_users_copy(path, platform, env)
403
517
 
404
518
 
405
519
  def host_registered(host: str, cwd: Path, env: Optional[Dict[str, str]] = None) -> bool:
@@ -416,7 +530,11 @@ def services_dir(platform: str = sys.platform, env: Optional[Dict[str, str]] = N
416
530
  """Where fabric-service/0.1 descriptors live: FABRIC_SERVICES_DIR, else Fabric's folder per OS + /services."""
417
531
  env = os.environ if env is None else env
418
532
  if env.get("FABRIC_SERVICES_DIR"):
419
- return Path(env["FABRIC_SERVICES_DIR"])
533
+ value = env["FABRIC_SERVICES_DIR"]
534
+ if value == "~" or value[:2] in ("~/", "~\\"): # `~` as the kit, Fabric and Fabric Dashboards expand it (FAA-189)
535
+ home = env.get("HOME") or env.get("USERPROFILE") or str(Path.home())
536
+ return Path(home) / value[2:] if len(value) > 2 else Path(home)
537
+ return Path(value)
420
538
  return fabric_root(platform, env) / "services"
421
539
 
422
540
 
@@ -632,6 +750,15 @@ def _write_private(path: Path, text: str) -> None:
632
750
  os.replace(str(tmp), str(path))
633
751
 
634
752
 
753
+ #: FAA-152: the turn's git branch goes to Fabric (contract DEC-0140 PR-3: shown on this machine's screens, never stored
754
+ #: or shared) unless the person turns it off with FABRIC_SESSION_NO_BRANCH set to anything but "" or "0".
755
+ NO_BRANCH_ENV = "FABRIC_SESSION_NO_BRANCH"
756
+
757
+
758
+ def branch_off(env: Optional[Dict[str, str]] = None) -> bool:
759
+ return (os.environ if env is None else env).get(NO_BRANCH_ENV, "") not in ("", "0")
760
+
761
+
635
762
  def repositories_off(env: Optional[Dict[str, str]] = None) -> bool:
636
763
  """Whether the person turned the transcript read off (FAA-160): FABRIC_SESSION_NO_REPOSITORIES set to anything but
637
764
  "" or "0", or the file NO_REPOSITORIES_MARK in the hooks' own folder."""
@@ -699,7 +826,7 @@ def prune_dispatch_records(env: Optional[Dict[str, str]] = None, now: Optional[f
699
826
  records = []
700
827
  for record in records:
701
828
  try:
702
- if now - record.stat().st_mtime > SESSION_STALE_S:
829
+ if now - record.lstat().st_mtime > SESSION_STALE_S: # the link's own age: a dangling one goes too (FAA-208)
703
830
  record.unlink()
704
831
  removed += 1
705
832
  except OSError:
@@ -104,6 +104,26 @@ def git_facts(cwd: Path, budget_s: Optional[float] = None, until: Optional[float
104
104
  return facts
105
105
 
106
106
 
107
+ #: The branch Fabric's fabric_session_observe takes (0.3.16, CO-459, its own pattern): a name outside it is not sent.
108
+ BRANCH = re.compile(r"[A-Za-z0-9._@+/-]{1,200}")
109
+
110
+
111
+ def git_branch(cwd: Path, until: Optional[float] = None) -> Optional[str]:
112
+ """The branch HEAD names (FAA-152), or None: a detached head, no checkout, or no answer before `until`."""
113
+ name = (_git(cwd, ("symbolic-ref", "--short", "-q", "HEAD"), until) or "").strip()
114
+ return name or None
115
+
116
+
117
+ def stored_branch(entry: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
118
+ """Whether a spool entry carries a branch to send, and which: a name BRANCH takes, or None for a detached head."""
119
+ if "branch" not in entry:
120
+ return False, None
121
+ value = entry["branch"]
122
+ if value is None:
123
+ return True, None
124
+ return (True, value) if isinstance(value, str) and BRANCH.fullmatch(value) else (False, None)
125
+
126
+
107
127
  def _count_status(z: str) -> int:
108
128
  records, count, skip = z.split("\0"), 0, False
109
129
  for record in records:
@@ -249,6 +269,9 @@ def turn_paths(transcript: Any, cwd: Path, until: Optional[float] = None) -> Lis
249
269
  calls.append(block)
250
270
  found: List[str] = []
251
271
  left = BASH_BYTES
272
+ # FAA-184: Claude Code keeps the Bash folder between calls while it stays inside the session's folder, so a call is
273
+ # read against the folder the one before it ended in; one outside, or one not known, is the session's folder again
274
+ folder = cwd
252
275
  for block in reversed(calls):
253
276
  if until is not None and time.monotonic() >= until:
254
277
  break
@@ -256,11 +279,16 @@ def turn_paths(transcript: Any, cwd: Path, until: Optional[float] = None) -> Lis
256
279
  if name in CHANGING_TOOLS:
257
280
  value = arguments.get(CHANGING_TOOLS[name])
258
281
  named = [os.path.normpath(os.path.join(str(cwd), value))] if isinstance(value, str) and value else []
259
- elif name == "Bash" and isinstance(arguments.get("command"), str) and left > 0 \
260
- and any(word in arguments["command"].lower() for word in ("cd", "git", "pushd")): # GIT.EXE on Windows
282
+ elif name == "Bash" and isinstance(arguments.get("command"), str) \
283
+ and any(word in arguments["command"].lower() for word in ("cd", "git", "pushd", "popd")): # GIT.EXE on Windows
284
+ if left <= 0:
285
+ folder = cwd # not read: where it left the shell is not known
286
+ continue
261
287
  command = arguments["command"][:left]
262
288
  left -= len(command)
263
- named = bash_paths(command, cwd)
289
+ ends: List[Optional[str]] = []
290
+ named = bash_paths(command, folder, ends=ends)
291
+ folder = Path(ends[-1]) if ends and ends[-1] and _inside(ends[-1], cwd) else cwd
264
292
  else:
265
293
  continue
266
294
  for path in named:
@@ -271,6 +299,15 @@ def turn_paths(transcript: Any, cwd: Path, until: Optional[float] = None) -> Lis
271
299
  return found
272
300
 
273
301
 
302
+ def _inside(folder: str, root: Path) -> bool:
303
+ """Whether `folder` is `root` or under it, as the host's paths compare (case-blind on Windows)."""
304
+ try:
305
+ common = os.path.commonpath([os.path.normcase(folder), os.path.normcase(str(root))])
306
+ except ValueError: # another drive, or a relative path
307
+ return False
308
+ return common == os.path.normcase(str(root))
309
+
310
+
274
311
  def _existing_folder(path: str) -> Optional[str]:
275
312
  """The path when it is a folder, else the nearest existing folder above it (a file's own folder)."""
276
313
  current = path
@@ -738,6 +775,11 @@ def deliver(directory: Path, env: Optional[Dict[str, str]] = None, deadline_s: f
738
775
  observedAt=entry.get("observedAt"), idempotencyKey=key)
739
776
  if event == "end":
740
777
  arguments["reason"] = reason
778
+ rule = _input_rule(client, OBSERVE_TOOL, "branch") if event == "turn" else None
779
+ carried, branch = stored_branch(entry)
780
+ # FAA-152: only where the tool declares it, never on the end, null only where its schema allows it
781
+ if rule is not None and carried and _allows(rule, branch):
782
+ arguments["branch"] = branch
741
783
  repositories = stored_repositories(entry) if event == "turn" else []
742
784
  fitted = [r for r in repositories if _fits(r, _repository_rules(client))][:takes] if takes else []
743
785
  if fitted:
@@ -853,6 +895,39 @@ def _input_fields(client: McpStdio, tool: str) -> List[str]:
853
895
  return []
854
896
 
855
897
 
898
+ def _input_rule(client: McpStdio, tool: str, field: str) -> Optional[Dict[str, Any]]:
899
+ """The schema a listed tool's input declares for one property, or None when it declares none."""
900
+ for listed in client.tools:
901
+ if listed.get("name") == tool:
902
+ schema = listed.get("inputSchema")
903
+ props = schema.get("properties") if isinstance(schema, dict) else None
904
+ rule = props.get(field) if isinstance(props, dict) else None
905
+ return rule if isinstance(rule, dict) else None
906
+ return None
907
+
908
+
909
+ def _allows(rule: Dict[str, Any], value: Optional[str], depth: int = 0) -> bool:
910
+ """Whether a string or null meets a property's schema as far as it is read here: `type` (a name or a list),
911
+ `pattern`, `maxLength`, `minLength`, `enum`, and `anyOf`/`oneOf` of such. Anything else unread: allowed."""
912
+ if depth > 4 or not isinstance(rule, dict):
913
+ return False
914
+ for key in ("anyOf", "oneOf"):
915
+ if isinstance(rule.get(key), list):
916
+ return any(_allows(option, value, depth + 1) for option in rule[key])
917
+ kinds = rule.get("type")
918
+ kinds = [kinds] if isinstance(kinds, str) else kinds if isinstance(kinds, list) else None
919
+ if value is None:
920
+ return kinds is not None and "null" in kinds
921
+ if kinds is not None and "string" not in kinds:
922
+ return False
923
+ try:
924
+ return not (len(value) > rule.get("maxLength", len(value)) or len(value) < rule.get("minLength", 0)
925
+ or ("enum" in rule and value not in rule["enum"])
926
+ or ("pattern" in rule and not re.search(rule["pattern"], value)))
927
+ except (TypeError, re.error):
928
+ return False
929
+
930
+
856
931
  def _fits(item: Dict[str, str], props: Dict[str, Any]) -> bool:
857
932
  """Whether one {host, owner, name} meets what the schema says of each field (type, length, pattern, enum): an item
858
933
  that does not is left out, so Fabric's check never refuses the whole turn for it (seam reading 4)."""
@@ -33,7 +33,8 @@ KEYWORDS = frozenset(("{", "!", "then", "do", "else", "elif", "if", "while", "un
33
33
  ASSIGNMENT = re.compile(r"[A-Za-z_][A-Za-z0-9_]*=")
34
34
 
35
35
 
36
- def bash_paths(command: str, cwd: Path, windows: Optional[bool] = None) -> List[str]:
36
+ def bash_paths(command: str, cwd: Path, windows: Optional[bool] = None,
37
+ ends: Optional[List[Optional[str]]] = None) -> List[str]:
37
38
  """The folders a Bash command names as `cd <absolute path>` or `git -C <path>`, in order. A relative `-C` is read
38
39
  against the folder the command last changed into with an absolute `cd`, else `cwd`; after a `cd` to a relative or
39
40
  unknown folder it is dropped (FAA-166). A `cd` inside `( )`, a pipeline's stage or a list sent to the background
@@ -42,7 +43,8 @@ def bash_paths(command: str, cwd: Path, windows: Optional[bool] = None) -> List[
42
43
  else of the command is kept; a command that does not split is [] (FAA-140). On Windows (`windows`, default
43
44
  `sys.platform == "win32"`) paths are read as Git Bash writes them, with its quoting: an unquoted `C:\\work` is
44
45
  `C:work` there too, so a native path counts when it is quoted (FAA-175); Cygwin and MSYS2 Pythons read their own
45
- POSIX paths (FAA-177)."""
46
+ POSIX paths (FAA-177). With `ends`, the folder the command leaves the shell in is appended to it: an absolute path,
47
+ or None when it is not known (a relative cd, popd, an unclosed subshell, a command that does not split; FAA-184)."""
46
48
  import shlex # only a Stop hook with a transcript needs it
47
49
  windows = sys.platform == "win32" if windows is None else windows
48
50
  if windows:
@@ -59,6 +61,8 @@ def bash_paths(command: str, cwd: Path, windows: Optional[bool] = None) -> List[
59
61
  lexer.whitespace_split = True
60
62
  tokens = list(lexer)
61
63
  except ValueError:
64
+ if ends is not None:
65
+ ends.append(None)
62
66
  return []
63
67
 
64
68
  def absolute(token: str) -> Optional[str]:
@@ -187,4 +191,7 @@ def bash_paths(command: str, cwd: Path, windows: Optional[bool] = None) -> List[
187
191
  found.append(at)
188
192
  start = False
189
193
  index += 1
194
+ if ends is not None:
195
+ end_pipeline()
196
+ ends.append(None if stack else base)
190
197
  return found