@passioncode-ai/fabric-agent-adapter 0.40.0 → 0.42.1

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 (89) hide show
  1. package/CHANGELOG.md +160 -0
  2. package/README.md +9 -3
  3. package/SECURITY.md +27 -6
  4. package/bin/fabric-agent-adapter.js +71 -6
  5. package/package.json +2 -2
  6. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +42 -66
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +4 -2
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +55 -0
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +9 -3
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/verification.md +15 -0
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +181 -26
  13. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +66 -5
  14. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +81 -23
  15. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +1 -1
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +28 -30
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +21 -9
  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 +2 -2
  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/support-agent/README.md +47 -7
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +230 -29
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +21 -5
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +2 -1
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +4 -2
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +3 -2
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +2 -2
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +5 -0
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +3 -2
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +3 -1
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +5 -1
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +68 -24
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +66 -5
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +1 -1
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +1 -1
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +53 -19
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +4 -2
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +145 -0
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +196 -188
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +5 -5
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +2 -2
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +6 -2
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +2 -1
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +1 -1
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +3 -1
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +48 -14
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +34 -1
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +157 -22
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +7 -3
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +12 -6
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +6 -6
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +17 -4
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +3 -3
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +162 -0
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +207 -227
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +4 -4
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +0 -1
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +52 -5
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +101 -45
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +12 -12
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +5 -5
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +13 -10
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +1 -1
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +6 -2
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +118 -71
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +272 -74
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +1 -1
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +1 -1
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +36 -11
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +3 -3
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +270 -35
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +25 -7
  73. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +29 -60
  74. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +4 -2
  75. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +55 -0
  76. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +66 -5
  77. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +3 -3
  78. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md +2 -1
  79. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +36 -16
  80. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +11 -0
  81. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +44 -22
  82. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +54 -6
  83. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +72 -13
  84. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +28 -7
  85. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +277 -49
  86. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +10 -1
  87. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/context.mjs +25 -2
  88. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/openclaw/index.mjs +4 -2
  89. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/opencode/fabric.mjs +2 -1
@@ -57,7 +57,7 @@ SESSION_ID = re.compile(r"[A-Za-z0-9._:-]{1,128}")
57
57
  LOOPBACK_ORIGIN = re.compile(r"http://127\.0\.0\.1:(\d{1,5})")
58
58
 
59
59
 
60
- VERSION = "0.40.0"
60
+ VERSION = "0.42.1"
61
61
 
62
62
 
63
63
  HOSTS = {"claude": "Claude Code", "codex": "Codex", "kimi": "Kimi Code",
@@ -401,24 +401,72 @@ def _process(pid: int) -> Tuple[Optional[str], int]:
401
401
  return None, 0
402
402
 
403
403
 
404
- def launched_by_ancestor(env: Optional[Dict[str, str]] = None, depth: int = 4) -> bool:
404
+ def launched_by_ancestor(env: Optional[Dict[str, str]] = None, depth: int = 4,
405
+ process: Optional[Callable[[int], Tuple[Optional[str], int]]] = None,
406
+ platform: str = sys.platform) -> Optional[bool]:
405
407
  """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
+ adapter mcp-config-flag) and no environment marker yet: look for that in this hook's few ancestors. True or False
409
+ once the walk is done; None when a process could not be read (a `ps` that failed or ran out of time), which is
410
+ "unknown", never "not launched" (FAA-68). Windows has no walk (`_process` reads no process there): False, so
411
+ only the environment signals count. Only the path is matched; nothing read here is kept or logged."""
412
+ if process is None and platform == "win32":
413
+ return False
408
414
  marker = os.path.normpath(str(fabric_root(env=env) / "sessions")) + os.sep
415
+ process = process or _process
409
416
  pid = os.getppid()
410
417
  for _ in range(depth):
411
418
  if pid <= 1:
412
419
  return False
413
- command, parent = _process(pid)
420
+ command, parent = process(pid)
414
421
  if command is None:
415
- return False
422
+ return None
416
423
  if "--mcp-config" in command and marker in command:
417
424
  return True
418
425
  pid = parent
419
426
  return False
420
427
 
421
428
 
429
+ #: A session rejoins at most once in this span (FAA-53): every rejoin spends Fabric's per-agent request budget, which
430
+ #: every session of the machine shares. The Stop worker and watch-mail both honour and renew the mark (FAA-68).
431
+ REJOIN_MIN_INTERVAL_S = 60.0
432
+
433
+
434
+ #: The file in a session's folder that says Fabric launched it: the ancestor walk ran once and found the launcher.
435
+ LAUNCHED_MARK = "launched"
436
+
437
+
438
+ #: The file in a session's folder that says Fabric held no session for its id: fabric-mcp --rejoin-only exited 3
439
+ #: (Fabric 0.3.9, TR-11). For NO_SESSION_HOLD_S the hooks spool nothing for that id; after it, one rejoin tries again,
440
+ #: so a Fabric that restarted and enrolled the id again is found (board thread 02bf19c5, ask 4).
441
+ NO_SESSION_MARK = "no-session"
442
+ NO_SESSION_HOLD_S = 15 * 60.0
443
+
444
+
445
+ def no_session_held(directory: Path, now: Optional[float] = None) -> bool:
446
+ """Whether a rejoin found no Fabric session for this id within NO_SESSION_HOLD_S."""
447
+ try:
448
+ mark = (directory / NO_SESSION_MARK).stat().st_mtime
449
+ except OSError:
450
+ return False
451
+ age = (time.time() if now is None else now) - mark
452
+ return 0 <= age < NO_SESSION_HOLD_S
453
+
454
+
455
+ def rejoined_recently(directory: Path, now: Optional[float] = None) -> bool:
456
+ """Whether this session's last rejoin succeeded within REJOIN_MIN_INTERVAL_S (the worker marks it)."""
457
+ return rejoin_wait(directory, now) > 0
458
+
459
+
460
+ def rejoin_wait(directory: Path, now: Optional[float] = None) -> float:
461
+ """Seconds until this session may rejoin again; 0 when it may now."""
462
+ try:
463
+ mark = (directory / "rejoined").stat().st_mtime
464
+ except OSError:
465
+ return 0.0
466
+ age = (time.time() if now is None else now) - mark
467
+ return REJOIN_MIN_INTERVAL_S - age if 0 <= age < REJOIN_MIN_INTERVAL_S else 0.0
468
+
469
+
422
470
  def log(line: str, env: Optional[Dict[str, str]] = None) -> None:
423
471
  """One structured line, 0600, rotated at 1 MB. Never raises."""
424
472
  try:
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import hashlib
6
6
  import json
7
7
  import os
8
+ import re
8
9
  import sys
9
10
  import time
10
11
  from pathlib import Path
@@ -101,6 +102,52 @@ def _kimi_block(command: str) -> str:
101
102
  "[[hooks]]\nevent = \"SessionStart\"\ncommand = %s\ntimeout = 5\n" % json.dumps(command))
102
103
 
103
104
 
105
+ _KIMI_COMMENT = _kimi_block("").split("[[hooks]]", 1)[0].lstrip("\n").splitlines(True) # our two comment lines
106
+
107
+
108
+ def _kimi_spans(text: str, host: str) -> List[Tuple[int, int]]:
109
+ """Where each `[[hooks]]` table whose command is ours sits in `text`, as line ranges [header, end). A table ends at
110
+ the next header; trailing blank and comment lines belong to what follows. Each table is read on its own with
111
+ tomllib, so a hand-touched table (another order, a comment, another timeout) is still found by what it runs."""
112
+ lines = text.splitlines(True)
113
+ headers = [i for i, line in enumerate(lines) if line.startswith("[")]
114
+ spans = []
115
+ for n, start in enumerate(headers):
116
+ if not re.match(r"\[\[\s*hooks\s*\]\]\s*(#.*)?\Z", lines[start].rstrip("\r\n")):
117
+ continue
118
+ end = headers[n + 1] if n + 1 < len(headers) else len(lines)
119
+ while end > start + 1 and (not lines[end - 1].strip() or lines[end - 1].lstrip().startswith("#")):
120
+ end -= 1
121
+ try:
122
+ table = tomllib.loads("".join(lines[start:end]))["hooks"][0]
123
+ except (ValueError, KeyError, IndexError, TypeError):
124
+ continue
125
+ if isinstance(table, dict) and _is_ours(table.get("command"), host):
126
+ spans.append((start, end))
127
+ return spans
128
+
129
+
130
+ def _kimi_edit(text: str, host: str, command: Optional[str]) -> str:
131
+ """`text` with our one `[[hooks]]` table pointed at `command`, or taken out (None) with the comment lines and the
132
+ blank line `_kimi_block` put before it. Refused when there is not exactly one."""
133
+ spans = _kimi_spans(text, host)
134
+ if len(spans) != 1:
135
+ raise Refused("found %d Fabric hook tables to edit; remove the extra ones by hand" % len(spans))
136
+ lines = text.splitlines(True)
137
+ start, end = spans[0]
138
+ if command is not None:
139
+ found = [i for i in range(start + 1, end) if re.match(r"\s*command\s*=", lines[i])]
140
+ if len(found) != 1:
141
+ raise Refused("the Fabric hook's command could not be found line by line; edit it by hand")
142
+ lines[found[0]] = "command = %s\n" % json.dumps(command)
143
+ return "".join(lines)
144
+ if lines[max(0, start - len(_KIMI_COMMENT)):start] == _KIMI_COMMENT:
145
+ start -= len(_KIMI_COMMENT)
146
+ if start > 0 and lines[start - 1] == "\n":
147
+ start -= 1
148
+ return "".join(lines[:start] + lines[end:])
149
+
150
+
104
151
  def _replace_file(path: Path, data: bytes, seen: Optional[bytes]) -> Optional[Path]:
105
152
  """Back the file up, then write `data` atomically with the file's own mode. Refused when the file changed
106
153
  since it was read. Returns the backup."""
@@ -163,9 +210,14 @@ def install_host_hooks_cli(argv: Sequence[str], env: Optional[Dict[str, str]] =
163
210
  path = Path(env.get("CODEX_HOME") or str(_home(env) / ".codex")) / "hooks.json"
164
211
  else:
165
212
  path = _home(env) / ".kimi-code" / "config.toml"
166
- command = hook_command(str(args.script or default_hook_script(env)), args.host)
213
+ script = Path(args.script).expanduser().absolute() if args.script else default_hook_script(env)
214
+ command = hook_command(str(script), args.host)
167
215
  shifted = 0 # Codex hook groups after ours, whose position a removal moves
216
+ updated = False # our own hook was there with another command, and is rewritten (FAA-69)
168
217
  try:
218
+ if not args.remove and not script.is_file():
219
+ # A hook pointing at nothing fails at every session start; removal needs no script.
220
+ raise Refused("--script %s is not a file; pass the fabric_session.py of a complete installed skill" % script)
169
221
  data = _read_bytes(path)
170
222
  digest = _digest(data)
171
223
  if args.expect_sha256 and args.expect_sha256 != digest:
@@ -201,37 +253,43 @@ def install_host_hooks_cli(argv: Sequence[str], env: Optional[Dict[str, str]] =
201
253
  out("install-host-hooks: the Fabric preamble hook is already in %s; nothing to change" % path)
202
254
  return 0
203
255
  owned[0]["command"] = command
256
+ updated = True
204
257
  new = (json.dumps(doc, indent=2) + "\n").encode("utf-8")
205
258
  else:
206
259
  doc.setdefault("hooks", {}).setdefault("SessionStart", []).append(entry)
207
260
  new = (json.dumps(doc, indent=2) + "\n").encode("utf-8")
208
- change = "remove from hooks.SessionStart" if args.remove else "add to hooks.SessionStart"
261
+ change = ("remove from" if args.remove else "update in" if updated else "add to") + " hooks.SessionStart"
209
262
  shown = json.dumps(entry, indent=2)
210
263
  else:
211
264
  block = _kimi_block(command)
212
265
  text = (data or b"").decode("utf-8", "replace")
213
- if not (args.apply or args.remove):
214
- present = tomllib is not None and any(_is_ours(h.get("command"), args.host) for h in _kimi_hooks(data))
215
- else:
216
- present = any(_is_ours(h.get("command"), args.host) for h in _kimi_hooks(data))
266
+ # Our table is found by what it runs (_is_ours), not by its exact text, so a stale path is repaired and a
267
+ # hand-touched table is still removed (FAA-69). A dry run without a TOML reader shows the plain append.
268
+ readable = tomllib is not None or args.apply or args.remove
269
+ owned = [h for h in _kimi_hooks(data) if _is_ours(h.get("command"), args.host)] if readable else []
270
+ if len(owned) > 1:
271
+ raise Refused("multiple Fabric hooks are configured in %s; remove the extra ones by hand" % path)
217
272
  if args.remove:
218
- if not present:
273
+ if not owned:
219
274
  out("install-host-hooks: no Fabric preamble hook in %s; nothing to change" % path)
220
275
  return 0
221
- if block not in text:
222
- raise Refused("the Fabric hook in %s was edited by hand; remove its [[hooks]] table yourself" % path)
223
- new_text = text.replace(block, "", 1)
224
- elif present:
276
+ new_text = _kimi_edit(text, args.host, None)
277
+ elif owned and owned[0].get("command") == command:
225
278
  out("install-host-hooks: the Fabric preamble hook is already in %s; nothing to change" % path)
226
279
  return 0
280
+ elif owned:
281
+ new_text = _kimi_edit(text, args.host, command)
282
+ updated = True
227
283
  else:
228
284
  new_text = text + ("" if not text or text.endswith("\n") else "\n") + block
229
285
  new = new_text.encode("utf-8")
230
286
  if args.apply or args.remove:
231
287
  after = _kimi_hooks(new)
232
- if len(after) != len(_kimi_hooks(data)) + (-1 if args.remove else 1):
288
+ mine = [h.get("command") for h in after if _is_ours(h.get("command"), args.host)]
289
+ growth = -1 if args.remove else 0 if updated else 1
290
+ if len(after) != len(_kimi_hooks(data)) + growth or mine != ([] if args.remove else [command]):
233
291
  raise Refused("the edited file would not read back as expected; nothing was changed")
234
- change = "remove from" if args.remove else "append to"
292
+ change = "remove from" if args.remove else "update in" if updated else "append to"
235
293
  shown = block.strip("\n")
236
294
  if not (args.apply or args.remove):
237
295
  out("install-host-hooks: dry run, nothing is written.")
@@ -248,6 +306,7 @@ def install_host_hooks_cli(argv: Sequence[str], env: Optional[Dict[str, str]] =
248
306
  return 0
249
307
  backup = _replace_file(path, new, data)
250
308
  out("install-host-hooks: %s %s %s." % ("removed the Fabric preamble hook from" if args.remove
309
+ else "updated the Fabric preamble hook in" if updated
251
310
  else "added the Fabric preamble hook to", path,
252
311
  "(backup: %s)" % backup if backup else "(new file)"))
253
312
  out("Undo: fabric_session.py install-host-hooks --host %s %s" % (args.host, "--apply" if args.remove else "--remove"))
@@ -15,13 +15,17 @@ from fabric_session_env import ( # noqa: E402 - the kit (FAA-65)
15
15
  UNREAD_LINE,
16
16
  VERSION,
17
17
  WORKER_DEADLINE_S,
18
+ _write_private,
18
19
  claude_registration,
19
20
  fabric_launched,
20
21
  fabric_root,
21
22
  locate_hub,
23
+ rejoin_wait,
24
+ session_dir,
22
25
  )
23
26
  from fabric_session_mcp import ( # noqa: E402 - the kit (FAA-65)
24
27
  McpStdio,
28
+ NoSessionError,
25
29
  _wait_for_session_tools,
26
30
  board_unread,
27
31
  )
@@ -36,9 +40,15 @@ WATCH_MAX_BACKOFF_S = 900
36
40
  def poll_unread(session_id: str, cwd: Path, env: Optional[Dict[str, str]] = None,
37
41
  deadline_s: float = WORKER_DEADLINE_S) -> Tuple[str, Any]:
38
42
  """One look at the board through `fabric-mcp --rejoin-only`: ("ok", count), ("unavailable", why) when Fabric
39
- answers but the board cannot be read, ("down", why) when Fabric cannot be reached. No fabric-mcp starts unless
40
- the running Fabric advertises session-hooks/1 (the rejoin)."""
43
+ answers but the board cannot be read, ("down", why) when Fabric cannot be reached, ("wait", seconds) when the
44
+ session rejoined within the last minute (the Stop worker's mark, or this one's): the rejoin budget is shared, so
45
+ no fabric-mcp starts then (FAA-68). No fabric-mcp starts unless the running Fabric advertises session-hooks/1
46
+ (the rejoin). A successful rejoin renews the mark in the session's folder when the hooks made one."""
41
47
  env = dict(os.environ if env is None else env)
48
+ directory = session_dir(session_id, env)
49
+ left = rejoin_wait(directory)
50
+ if left > 0:
51
+ return "wait", left
42
52
  hub = locate_hub(fabric_root(env=env))
43
53
  if hub["state"] != "running":
44
54
  return "down", "Fabric is not running (%s)" % hub["reason"]
@@ -56,10 +66,17 @@ def poll_unread(session_id: str, cwd: Path, env: Optional[Dict[str, str]] = None
56
66
  "clientInfo": {"name": "fabric-agent-adapter-watch-mail", "version": VERSION}})
57
67
  client.notify("notifications/initialized")
58
68
  names = _wait_for_session_tools(client, deadline)
69
+ if directory.is_dir(): # the hooks made it, after their launch check; never made here
70
+ try:
71
+ _write_private(directory / "rejoined", "")
72
+ except OSError:
73
+ pass
59
74
  if BOARD_STATUS_TOOL not in names:
60
75
  return "unavailable", "this Fabric serves no %s" % BOARD_STATUS_TOOL
61
76
  unread, said = board_unread(client.request("tools/call", {"name": BOARD_STATUS_TOOL, "arguments": {}}))
62
77
  return ("ok", unread) if unread is not None else ("unavailable", "the project board cannot be read (%s)" % said)
78
+ except NoSessionError:
79
+ return "down", "Fabric holds no session for this id"
63
80
  except TimeoutError:
64
81
  return "down", "fabric-mcp did not join this session in time"
65
82
  except Exception as exc: # noqa: BLE001 — a poll never raises
@@ -72,15 +89,18 @@ def poll_unread(session_id: str, cwd: Path, env: Optional[Dict[str, str]] = None
72
89
  def watch_mail(session_id: str, cwd: Path, env: Optional[Dict[str, str]] = None, interval: float = 60, *,
73
90
  poll: Optional[Callable[[], Tuple[str, Any]]] = None, out: Callable[[str], None] = print,
74
91
  wait: Callable[[float], bool], stop: Callable[[], bool], rng: Callable[[], float]) -> int:
75
- """Poll the board every `interval` seconds (±20 % jitter, never under WATCH_MIN_INTERVAL_S) and print ONE line each
76
- time the unread count rises. While Fabric is down or the board unreadable, say so once and back off, doubling up
77
- to WATCH_MAX_BACKOFF_S. `wait(seconds)` returns True when it was told to stop."""
92
+ """Poll the board every `interval` seconds (±20 % jitter; the delay after jitter stays within WATCH_MIN_INTERVAL_S
93
+ and WATCH_MAX_BACKOFF_S) and print ONE line each time the unread count rises. While Fabric is down or the board
94
+ unreadable, say so once and back off, doubling up to WATCH_MAX_BACKOFF_S. A look the shared rejoin mark holds back
95
+ ("wait") changes nothing and waits it out. `wait(seconds)` returns True when it was told to stop."""
78
96
  interval = max(float(WATCH_MIN_INTERVAL_S), float(interval))
79
97
  poll = poll or (lambda: poll_unread(session_id, cwd, env))
80
98
  last, trouble, backoff = 0, None, interval
81
99
  while not stop():
82
100
  kind, value = poll()
83
- if kind == "ok":
101
+ if kind == "wait":
102
+ delay = float(value)
103
+ elif kind == "ok":
84
104
  if value > last:
85
105
  out(UNREAD_LINE % value)
86
106
  last, trouble, backoff = value, None, interval
@@ -91,7 +111,8 @@ def watch_mail(session_id: str, cwd: Path, env: Optional[Dict[str, str]] = None,
91
111
  out("watch-mail: %s; trying again, backing off up to %d minutes" % (value, WATCH_MAX_BACKOFF_S // 60))
92
112
  trouble = kind
93
113
  delay = backoff
94
- if wait(delay * (0.8 + 0.4 * rng())):
114
+ jittered = delay if kind == "wait" else delay * (0.8 + 0.4 * rng())
115
+ if wait(min(max(jittered, float(WATCH_MIN_INTERVAL_S)), float(WATCH_MAX_BACKOFF_S))):
95
116
  break
96
117
  return 0
97
118