@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.
- package/CHANGELOG.md +256 -0
- package/README.md +14 -7
- package/bin/fabric-agent-adapter.js +26 -4
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/hooks/hooks.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +6 -4
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/agents-database.md +4 -4
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +42 -7
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +13 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +3 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +12 -4
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +23 -8
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +6 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +6 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +34 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/README.md +67 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/dashboard_service.py +252 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/install.mjs +168 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/install.py +179 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/README.md +57 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/door.mjs +181 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/door.py +237 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/agents-database.md +4 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +11 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +42 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +12 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +26 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +14 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +44 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +12 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +7 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +10 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +5 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +2 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +15 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-context.mjs +37 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +30 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +48 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +9 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +16 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-load.mjs +344 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +54 -20
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +4 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +8 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +4 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +8 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +55 -14
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +8 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +6 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +3 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +16 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +97 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_context.py +50 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +22 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +49 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +9 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +15 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_load.py +358 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +41 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +6 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +17 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +24 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +110 -16
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +12 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +543 -37
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +4 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +12 -5
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-database.md +4 -4
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +42 -7
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +6 -3
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +13 -3
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/resilience-and-autonomy.md +13 -4
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/where-agents-run.md +32 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/working-rules.md +54 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +12 -4
- package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +27 -26
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/agents-database.md +4 -4
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/cleanup.md +30 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +59 -38
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +52 -26
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/remote-sessions.md +75 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +28 -13
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/task-context.md +58 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/waits.md +36 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +14 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +169 -46
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +146 -19
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +78 -3
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_paths.py +9 -2
package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
396
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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:
|
package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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)."""
|
package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_paths.py
CHANGED
|
@@ -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
|
|
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
|