@passioncode-ai/fabric-agent-adapter 0.62.0 → 0.63.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/README.md +6 -3
  3. package/SECURITY.md +3 -1
  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 +1 -1
  8. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +1 -1
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +48 -7
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/channel-agent.example.json +103 -0
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +1 -0
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +141 -17
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +2 -2
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +2 -0
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +2 -2
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +1 -1
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +93 -3
  18. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +2 -2
  19. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-that-answer-people.md +5 -2
  20. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/practices.md +46 -0
  21. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +16 -15
  22. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md +7 -1
  23. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +209 -0
  24. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +34 -6
  25. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +29 -5
  26. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +362 -7
  27. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +35 -6
  28. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +4 -1
  29. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +8 -1
@@ -4,11 +4,14 @@ from __future__ import annotations
4
4
 
5
5
  import json
6
6
  import os
7
+ import re
7
8
  import sys
8
9
  import time
9
10
  from pathlib import Path
11
+ from typing import Any, Dict, List, Optional, Sequence, Tuple
10
12
 
11
13
  from fabric_session_env import ( # noqa: E402 - the kit (FAA-65)
14
+ DISPATCH_WORKFLOW,
12
15
  HOOK_FEATURE,
13
16
  HOSTS,
14
17
  LAUNCHED_MARK,
@@ -35,6 +38,7 @@ from fabric_session_env import ( # noqa: E402 - the kit (FAA-65)
35
38
  take_nudges,
36
39
  nudge_shown,
37
40
  project_uses_fabric,
41
+ prune_dispatch_records,
38
42
  prune_stale_sessions,
39
43
  read_small,
40
44
  rejoined_recently,
@@ -43,6 +47,7 @@ from fabric_session_env import ( # noqa: E402 - the kit (FAA-65)
43
47
  session_dir,
44
48
  spawn_worker,
45
49
  spool,
50
+ state_root,
46
51
  )
47
52
  from fabric_session_mcp import ( # noqa: E402 - the kit (FAA-65)
48
53
  FACTS_BUDGET_S,
@@ -62,7 +67,9 @@ PREAMBLE = (
62
67
  "[fabric-agent-adapter] Fabric is running and this {host} has its MCP server `fabric` registered. "
63
68
  "Registration is not proof of connection or admission. "
64
69
  "Call `fabric_whoami` first. Then read the project board: `com.status` (is it ready, how many unread), "
65
- "`com.list` for the messages, and `com.read_ack` only for what you actually read. "
70
+ "`com.list` for the messages, and `com.read_ack` only for what you actually read. A request, announcement or "
71
+ "question waiting on your Project gets an answer in the same turn: its state now (done, where, when, what "
72
+ "blocks), the result later in a second message; a request a responder should take stays queued. "
66
73
  "Before you start work, call `fabric_work_list` (who is working on what here) and "
67
74
  "claim your task with `fabric_work_claim`; release it with `fabric_work_release` when you stop. A Fabric older "
68
75
  "than v0.3.5 has `fabric_tasks_list`, `fabric_task_claim` and `fabric_task_release` instead. Link a backlog row "
@@ -94,6 +101,313 @@ DOWN_NOTICE = (
94
101
  QUIET_SOURCES = frozenset(("compact", "resume"))
95
102
 
96
103
 
104
+ #: FAA-201: a session Fabric started on a board request (Fabric's on-demand spec, sections 3.4 to 3.6). The
105
+ #: environment names the workflow, the handoff and the request, and so does the prompt's first line `fabric-dispatch/1
106
+ #: workflow=wf_<16 hex> handoff=<handoff id> request=<uuid>` (fields one space apart, in that order). A session is the
107
+ #: run only when its own prompt's first line names the same three as the environment (Fabric's spec 3.5, agreed on the
108
+ #: project board, 2026-11-05): the environment alone makes no run, since a reader or a subshell inherits it; a line
109
+ #: that disagrees, or an environment without all three, makes none either. A line with no workflow in the environment
110
+ #: is quoted text.
111
+ DISPATCH_ENV = (("workflow", "FABRIC_DISPATCH_WORKFLOW"), ("handoff", "FABRIC_DISPATCH_HANDOFF"),
112
+ ("request", "FABRIC_DISPATCH_REQUEST"))
113
+ _DISPATCH_VALUE = {"workflow": DISPATCH_WORKFLOW, "handoff": r"handoff:[A-Za-z0-9._:-]{1,64}",
114
+ # Switchboard takes a request in either case (uuid::Uuid::parse_str) and writes one string to the
115
+ # line and the environment, so the hooks take either case and compare exactly, as the contract says
116
+ "request": r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"}
117
+ #: Fabric's dispatch id, a UUID, within the form Switchboard's `--dispatch-id` takes (board thread b3e60686): a value
118
+ #: outside it keys nothing, and the holder falls back to the workflow, the handoff and the launcher's run id.
119
+ DISPATCH_ID = re.compile(r"[A-Za-z0-9._-]{1,64}")
120
+ _WINDOWS = os.name == "nt"
121
+ DISPATCH_LINE = re.compile(r"fabric-dispatch/1 workflow=(%(workflow)s) handoff=(%(handoff)s) request=(%(request)s)"
122
+ % _DISPATCH_VALUE)
123
+ DISPATCH_HEAD = "[fabric-agent-adapter] Fabric started this session on one board request ({request}; workflow {workflow})."
124
+ #: Before the prompt is seen (session start; Codex and Kimi have no prompt hook) the contract is conditional.
125
+ DISPATCH_IF = (
126
+ "[fabric-agent-adapter] This session is the run on a board request only when its own prompt's first line is "
127
+ "exactly `fabric-dispatch/1 workflow={workflow} handoff={handoff} request={request}`. No such line: it is a helper; do your task only, touching neither the handoff, the "
128
+ "request nor the workflow. That line with other values: no run; say so in the request's thread and stop. The run:")
129
+ #: The conditional contract at its longest (a 64-character handoff and a session id) is held under Hermes' 4000
130
+ #: characters by test_a_native_bridge_is_always_told_the_contract_and_writes_no_record; measure before adding words.
131
+ DISPATCH_CONTRACT = (
132
+ " Do that "
133
+ "request and end: nobody watches this run, so never wait for a person. 1. `fabric_whoami`, then read the packet "
134
+ "with `observatory_checkpoint_latest` {{workflowId: {workflow}}}: obey its constraints first; its goal describes "
135
+ "the request; its artifacts are references (the capability, the deadline, run-ends-at; Observatory's write redacts "
136
+ "the board ones). Read the request with `com.get` {{message: <request>}} and its thread with `com.list` "
137
+ "{{thread: <the `thread` com.get answered>}}. 2. "
138
+ "`observatory_handoff_accept` with {handoff}, the `owner`, `idempotencyKey` and `executor` the launcher's prompt names "
139
+ "(when it names none: your `owner` `agent:<name>`, the key `accept-<handoff>`, no executor) and this session's own "
140
+ "`sessionId` (the same on a retry) takes the workflow's lease; keep the `leaseId`. A lapsed or refused handoff holds "
141
+ "no lease: end the run without touching the request. 3. `com.status` once for the "
142
+ "epoch and the slot's generation; `com.enroll` for the capability, then `com.claim` (`idempotency`, `capability`, "
143
+ "`request`), `com.accept` and `com.effect_begin` before an effect. When `com.get` shows the request accepted or in "
144
+ "progress (an earlier run), take an accepted one over with `com.responder_replace` (`expectedGeneration`) and its "
145
+ "transfer, settle `outcome_unknown` only with `com.reconcile` and an observed result, and never complete what you "
146
+ "did not observe. After the lease, the claim and the reply, write an in_progress checkpoint (`workflowId`, "
147
+ "`leaseId`). Every checkpoint you write repeats `goal`, `constraints`, `artifacts` and `credentials` of "
148
+ "`checkpoint.body` (from `observatory_checkpoint_latest`; never its top-level `credentials` or the accept answer's "
149
+ "`pack`, which Observatory refuses back) unchanged and adds `open`, `done` or `notes`, never a value: Observatory keeps only the latest body. 4. Do the work; renew with `com.renew` or `com.progress` about every 20 s in a long step "
150
+ "and finish before run-ends-at; work that cannot end by then gives its state as a `com.progress` note, "
151
+ "then: when the request's deadline is not after run-ends-at plus 10 minutes (no run starts that close to it), `com.complete` failed_known saying so and steps 6 and 7; "
152
+ "otherwise it is left accepted for the next run, an in_progress checkpoint without `close`, steps 5 and 6 "
153
+ "skipped. Money, deletion and publication go through `fabric_effect_request`; a sign-in, a one-time code, a typed "
154
+ "secret or a payment is a person's (none is in this chat), and so is a production step unless the operator's "
155
+ "words, relayed verbatim, dated and sourced, from his trusted projects with a scope naming it, approve it: name it in "
156
+ "the answer. 5. `com.reply` in the thread, then "
157
+ "`com.complete` with an observed outcome: completed, or failed_known with the reason. 6. "
158
+ "`observatory_checkpoint_write` with `workflowId` {workflow}, the same `owner`, an `idempotencyKey`, a `stepId`, "
159
+ "the `leaseId`, `close: true`, a status and the packet's body with `done` and `notes` added: done for completed; for "
160
+ "failed_known failed once Observatory takes it (OBS-323), until then blocked with the reason in notes. 7. Write the retro "
161
+ "`fabric_whoami` asks for (`observatory_lesson` and insight/1 lines, or one line that there is nothing; no "
162
+ "values), then end the run: no board sweep, no work claim, "
163
+ "no watch-mail. Details: working-with-fabric "
164
+ "references/on-demand-session.md."
165
+ )
166
+ #: A second session whose own prompt starts with the same dispatch line (another process under the run's environment):
167
+ #: the first such session holds the dispatch, and any other gets this line (0.63.0 seam reading 2).
168
+ DISPATCH_NESTED = (
169
+ "[fabric-agent-adapter] This session runs inside a run Fabric started on a board request (workflow {workflow}); "
170
+ "that run holds it. Do the task you were given only: do not accept the handoff, claim the request, replace its "
171
+ "responder, complete it or write the workflow's checkpoints."
172
+ )
173
+ #: The prompt's line confirms the run (0.63.0, Fabric's spec 3.5): the contract printed at the start applies.
174
+ DISPATCH_IS_RUN = ("[fabric-agent-adapter] This prompt's first line names the board request FABRIC_DISPATCH_* names: "
175
+ "this session is the run, and the contract its start printed applies (working-with-fabric "
176
+ "references/on-demand-session.md).")
177
+ #: A line and an environment that disagree make no run: the session says so and stops (Fabric's spec 3.5).
178
+ DISPATCH_OTHER = ("[fabric-agent-adapter] This prompt's first line and FABRIC_DISPATCH_* disagree on {fields}: this "
179
+ "session is no run. Say so in the request's thread (`com.get` {{message: <the request the line "
180
+ "names>}}, then `com.submit` kind `message` to its thread, naming only the ids), take nothing, write "
181
+ "no checkpoint, and stop.")
182
+ #: FABRIC_DISPATCH_WORKFLOW without a well-formed handoff and request: no session under it is a run.
183
+ DISPATCH_INCOMPLETE = ("[fabric-agent-adapter] FABRIC_DISPATCH_WORKFLOW ({workflow}) is set without a well-formed "
184
+ "FABRIC_DISPATCH_HANDOFF and FABRIC_DISPATCH_REQUEST: no session here is a run Fabric started on "
185
+ "a board request. Do the task you were given only; never accept the handoff, claim, complete the "
186
+ "request or write the workflow's checkpoints. A prompt that starts with `fabric-dispatch/1` makes "
187
+ "no run either: say in the request's thread that the launcher's environment lacks them, and "
188
+ "stop.")
189
+
190
+
191
+ def dispatch_from_env(env: Optional[Dict[str, str]] = None) -> Optional[Dict[str, str]]:
192
+ """The dispatch the environment names: None unless FABRIC_DISPATCH_WORKFLOW is well formed; the handoff and the
193
+ request only when they are (FAA-201)."""
194
+ env = os.environ if env is None else env
195
+ found = {}
196
+ for field, name in DISPATCH_ENV:
197
+ value = env.get(name)
198
+ if isinstance(value, str) and re.fullmatch(_DISPATCH_VALUE[field], value):
199
+ found[field] = value
200
+ elif field == "workflow":
201
+ return None
202
+ return found
203
+
204
+
205
+ def dispatch_from_line(text: Any) -> Optional[Dict[str, str]]:
206
+ """The dispatch a prompt's first line names, exactly as Fabric writes it, or None."""
207
+ if not isinstance(text, str):
208
+ return None
209
+ first = text.split("\n", 1)[0].rstrip("\r")
210
+ match = DISPATCH_LINE.fullmatch(first)
211
+ return dict(zip(("workflow", "handoff", "request"), match.groups())) if match else None
212
+
213
+
214
+ def dispatch_complete(told: Optional[Dict[str, str]]) -> bool:
215
+ """Whether the environment names all three: only then can a session under it be the run."""
216
+ return told is not None and all(field in told for field, _ in DISPATCH_ENV)
217
+
218
+
219
+ def dispatch_of(env: Optional[Dict[str, str]], prompt: Any = None) -> Tuple[Optional[Dict[str, str]], List[str]]:
220
+ """(the dispatch, the fields the prompt's line and the environment disagree on). The dispatch is the environment's
221
+ when it names all three and the prompt's first line names the same three: that session is the run (Fabric's spec
222
+ 3.5). No line, or no environment, is (None, []); a line that disagrees, or an environment without all three, is
223
+ (None, the fields)."""
224
+ told, line = dispatch_from_env(env), dispatch_from_line(prompt)
225
+ if told is None or line is None:
226
+ return None, []
227
+ # exactly, as the conditional contract tells a host with no prompt hook to compare (0.63.0 seam reading 21)
228
+ fields = [field for field, _ in DISPATCH_ENV if told.get(field) != line[field]]
229
+ return (dict(told), []) if not fields else (None, fields)
230
+
231
+
232
+ #: Observatory takes a sessionId only as a lowercase UUID (0.63.0 seam reading 8): one the hook was given is named.
233
+ _SESSION_UUID = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")
234
+ DISPATCH_SESSION = " This session's sessionId: {session}."
235
+ #: After a /clear the accept, if one was made, was made under the session id the clear replaced (0.63.0 seam readings
236
+ #: 9 and 10): Observatory replays an accept, with its leaseId, only for that sessionId and the same idempotencyKey. A
237
+ #: pending handoff is accepted under that same id, so every later replay matches it (0.63.0 seam reading 13).
238
+ #: Observatory keys the replay on the owner and the executor as passed. Once this dispatch's handoff is accepted, the
239
+ #: lease checkpoint_latest shows survives the clear: its `holder` is the first accept's owner, and its `executor` merges
240
+ #: the offer (a provider only, Fabric's spec 3.5) with what was passed, so it is that executor when it carries an
241
+ #: accountRef (0.63.0 seam readings 14 and 15). While the handoff is pending the lease is Fabric's own (its creator,
242
+ #: or its restart hand-over to service:fabric), never the run's (0.63.0 product reading 16).
243
+ DISPATCH_CLEARED = (" This session was cleared: an accept already made stands, and its leaseId comes back only by "
244
+ "replaying it exactly: {session}, the same idempotencyKey ({key}), the same `owner` and the "
245
+ "`executor` as the first accept passed them. Once the handoff is taken (neither pending nor "
246
+ "lapsed, and the lease's `handoffId` is it), the lease `observatory_checkpoint_latest` shows is that accept's: its `holder` is the "
247
+ "owner, and its `executor` the one passed when it carries an `accountRef`, none when it shows a "
248
+ "provider alone. A replay Observatory refuses gets no lease back: end the run without touching "
249
+ "the request; Fabric takes the workflow over after it.{tail}")
250
+ #: With the replaced id unknown there is nothing to replay: the notice says so first (0.63.0 product reading 17).
251
+ DISPATCH_CLEARED_UNKNOWN = (" This session was cleared, and these hooks cannot name the session id the clear "
252
+ "replaced: an accept already made under it cannot be replayed, so when "
253
+ "`observatory_checkpoint_latest` shows the handoff taken and this session holds no leaseId "
254
+ "for it, end the run without touching the request; Fabric takes the workflow over after "
255
+ "it.{tail}")
256
+ #: A pending handoff's lease is Fabric's; a lapsed one (`lapsedHandoff`) was never accepted and no longer can be.
257
+ #: Observatory shows the workflow's newest offer, which may be another hand-over's (0.63.0 seam reading 18).
258
+ _CLEARED_TAIL = (" Read only this dispatch's handoff ({hname}): when `observatory_checkpoint_latest` shows another one, "
259
+ "pending, lapsed or in the lease, end the run without touching the request."
260
+ " When `observatory_checkpoint_latest` shows the handoff lapsed, no accept was made and none can be: "
261
+ "end the run without touching the request. When "
262
+ "`observatory_checkpoint_latest` still shows the handoff pending, no accept was made and the lease it "
263
+ "shows is Fabric's, not this run's: {pending}")
264
+ _PENDING_NAMED = ("accept it now with that same sessionId, {pkey}, the owner {owner} and no executor (the launcher's "
265
+ "prompt is gone). Never accept it under the new id.")
266
+ _PENDING_UNKNOWN = ("accept it as step 2 says, with {pkey}, the owner {owner}, no executor and this session's own "
267
+ "sessionId{session}.")
268
+
269
+
270
+ def dispatch_cleared(previous: Any, handoff: Any = None, session_id: Any = None) -> str:
271
+ """The cleared holder's notice: replay under the id the clear replaced when it is known, and accept a pending
272
+ handoff under that same id; when it is not known, nothing can be replayed and a pending handoff is accepted under
273
+ this session's id. A pending accept takes its owner and key from step 2, never from the lease, which is Fabric's
274
+ until the handoff is taken."""
275
+ named = isinstance(previous, str) and _SESSION_UUID.fullmatch(previous)
276
+ switchboard = isinstance(handoff, str) and bool(handoff)
277
+ # step 2's key is `accept-<handoff>` whoever launched the run, so every /clear can replay it (product reading 18)
278
+ key = "`accept-%s`" % handoff if switchboard else "`accept-<handoff>`, the handoff the packet names"
279
+ owner = ("`agent:<provider>` (such as `agent:claude-code`) when Switchboard started the run, else `agent:<name>`"
280
+ if switchboard else "`agent:<name>` step 2 names")
281
+ pkey = "the key %s" % key
282
+ hname = handoff if switchboard else "the one the packet names"
283
+ if named:
284
+ tail = _CLEARED_TAIL.format(hname=hname, pending=_PENDING_NAMED.format(pkey=pkey, owner=owner))
285
+ return DISPATCH_CLEARED.format(session="sessionId %s" % previous, key=key, tail=tail)
286
+ own = isinstance(session_id, str) and _SESSION_UUID.fullmatch(session_id)
287
+ pending = _PENDING_UNKNOWN.format(pkey=pkey, owner=owner, session=" (%s)" % session_id if own else "")
288
+ return DISPATCH_CLEARED_UNKNOWN.format(tail=_CLEARED_TAIL.format(hname=hname, pending=pending))
289
+
290
+
291
+ def dispatch_contract(dispatch: Dict[str, str], session_id: Any = None, conditional: bool = False) -> str:
292
+ """The exit contract for a dispatch the environment names in full: conditional on the prompt's first line before
293
+ the prompt is seen, or as the run's once it is known to be (a cleared holder)."""
294
+ told = DISPATCH_SESSION.format(session=session_id) if isinstance(session_id, str) and _SESSION_UUID.fullmatch(
295
+ session_id) else ""
296
+ return ((DISPATCH_IF if conditional else DISPATCH_HEAD) + DISPATCH_CONTRACT).format(**dispatch) + told
297
+
298
+
299
+ def _dispatch_record(dispatch: Dict[str, str], env: Dict[str, str]) -> Tuple[Path, str]:
300
+ """The folder and the key of a dispatch's record: FABRIC_DISPATCH_ID when it is one, else the workflow, the handoff
301
+ and the launcher's run id, so a new launch is a new holder."""
302
+ import hashlib
303
+ dispatch_id = env.get("FABRIC_DISPATCH_ID") or ""
304
+ if DISPATCH_ID.fullmatch(dispatch_id): # Fabric's own id for one dispatch, a UUID (Switchboard sets it since 0.7.8)
305
+ run = "|".join((dispatch["workflow"], "dispatch", dispatch_id))
306
+ else:
307
+ run = "|".join((dispatch["workflow"], dispatch.get("handoff") or "", env.get("SWITCHBOARD_LAUNCH_ID") or ""))
308
+ return state_root(env=env) / "dispatch", hashlib.sha256(run.encode("utf-8")).hexdigest()[:32]
309
+
310
+
311
+ def dispatch_cleared_from(dispatch: Dict[str, str], env: Optional[Dict[str, str]] = None) -> Optional[str]:
312
+ """The session id a /clear replaced, when this dispatch's holder came in by one; None otherwise."""
313
+ env = os.environ if env is None else env
314
+ folder, key = _dispatch_record(dispatch, env)
315
+ try:
316
+ held = json.loads(read_small(folder / (key + ".json")) or "null")
317
+ except ValueError:
318
+ return None
319
+ if not isinstance(held, dict) or "cleared_from" not in held:
320
+ return None
321
+ return str(held["cleared_from"] or "") # "" marks a clear whose replaced id is unknown
322
+
323
+
324
+ def dispatch_holder(dispatch: Dict[str, str], session_id: Any, env: Optional[Dict[str, str]] = None,
325
+ take_over: bool = False) -> bool:
326
+ """Whether this session holds the dispatch: the first session id seen for the dispatch (its FABRIC_DISPATCH_ID when
327
+ the launcher sets one, else the workflow, the handoff and the launcher's run id) holds it, recorded once in the hooks' folder (0600, created exclusively). `take_over` (a `/clear`, which gives
328
+ the same run a new session id; a nested `claude -p` starts with `startup`) moves the record to this id when the
329
+ record was written under this hook's parent (the agent's own process, which a `/clear` keeps); a missing or
330
+ unreadable record holds nothing on a take-over, since only a prompt's line makes the run. A session id that is not
331
+ one, or a folder that cannot be written, counts as the holder outside a take-over: better the contract twice than
332
+ never. A record that cannot be read (damaged by hand, or created and not yet written by a hook running at the same
333
+ moment) counts as held by another session: the session it names may be at work."""
334
+ env = os.environ if env is None else env
335
+ if not isinstance(session_id, str) or not SESSION_ID.fullmatch(session_id):
336
+ return not take_over # outside a take-over the contract twice rather than never; a clear holds nothing
337
+ folder, key = _dispatch_record(dispatch, env)
338
+ record = {"session": session_id, "at": int(time.time()), "host": os.getppid()}
339
+ if take_over:
340
+ try:
341
+ held = json.loads(read_small(folder / (key + ".json")) or "null")
342
+ except ValueError:
343
+ held = None
344
+ # no record: no prompt of this process confirmed the run (Fabric's spec 3.5), so the clear holds nothing. On
345
+ # Windows the hook's parent is the Git Bash a hook runs in, a new one each time, so a /clear cannot tell the
346
+ # holder from a helper there: it holds nothing either, and Fabric takes the workflow over after the run
347
+ # (0.63.0 product reading 20)
348
+ if not isinstance(held, dict) or _WINDOWS:
349
+ return False
350
+ if held.get("host") == record["host"]:
351
+ # the session the clear replaced, the one an earlier accept named: only when this hook's parent wrote the
352
+ # record. A record already cleared keeps the id it named; one cleared with the id unknown names the session
353
+ # it started, which was told to accept a pending handoff under its own id (0.63.0 seam reading 13). An id
354
+ # the notice cannot name (not a lowercase UUID) is not carried on: the session it started was told to
355
+ # accept under its own id (0.63.0 unit reading 14)
356
+ kept = held.get("cleared_from")
357
+ named = kept if isinstance(kept, str) and _SESSION_UUID.fullmatch(kept) else held.get("session")
358
+ record["cleared_from"] = named if isinstance(named, str) else ""
359
+ try:
360
+ folder.mkdir(parents=True, exist_ok=True, mode=0o700)
361
+ _write_private(folder / (key + ".json"), json.dumps(record))
362
+ except OSError:
363
+ pass
364
+ return True
365
+ # another agent process holds it: this is a session inside the run that cleared, still nested
366
+ try:
367
+ folder.mkdir(parents=True, exist_ok=True, mode=0o700)
368
+ fd = os.open(str(folder / (key + ".json")), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
369
+ except FileExistsError:
370
+ held = read_small(folder / (key + ".json")) or ""
371
+ try:
372
+ return json.loads(held).get("session") == session_id
373
+ except (ValueError, AttributeError):
374
+ return False
375
+ except OSError:
376
+ return True
377
+ try:
378
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
379
+ handle.write(json.dumps(record))
380
+ except OSError: # a full disk: no half record that would make this session a stranger later
381
+ try:
382
+ (folder / (key + ".json")).unlink()
383
+ except OSError:
384
+ pass
385
+ return True
386
+
387
+
388
+ def dispatch_held_elsewhere(dispatch: Dict[str, str], session_id: Any, env: Optional[Dict[str, str]] = None) -> bool:
389
+ """Whether a record for the dispatch exists and does not name this session: dispatch_holder then answers no for
390
+ it at every prompt (a record that cannot be read counts as another session's). A session id that is not one counts
391
+ as the holder in dispatch_holder and its prompt hook says nothing to it, so it is never told it is nested."""
392
+ env = os.environ if env is None else env
393
+ if not isinstance(session_id, str) or not SESSION_ID.fullmatch(session_id):
394
+ return False
395
+ folder, key = _dispatch_record(dispatch, env)
396
+ # lexists: a dangling link is a record dispatch_holder's O_EXCL refuses, and it never raises (0.63.0 unit reading 24)
397
+ if not os.path.lexists(str(folder / (key + ".json"))):
398
+ return False
399
+ try:
400
+ return json.loads(read_small(folder / (key + ".json")) or "null").get("session") != session_id
401
+ except (ValueError, AttributeError):
402
+ return True
403
+
404
+
405
+ def _host_output(host: str, event: str, context: str) -> str:
406
+ if host not in ("claude", "codex"):
407
+ return context
408
+ return json.dumps({"hookSpecificOutput": {"hookEventName": event, "additionalContext": context}})
409
+
410
+
97
411
  def read_hook_input() -> Dict[str, Any]:
98
412
  try:
99
413
  text = "" if sys.stdin is None or sys.stdin.isatty() else sys.stdin.read(1024 * 1024)
@@ -108,8 +422,29 @@ def hook_session_start(payload: Dict[str, Any], env: Optional[Dict[str, str]] =
108
422
  """What to print, or None: Claude/Codex hook JSON, plain context for native plugins and Kimi. The preamble when the
109
423
  host has `fabric` registered, Fabric runs and the project uses Fabric (project_uses_fabric); a one-line pointer
110
424
  when the project shows no sign (FAA-60); when Fabric is down, one line, and only in a project that uses Fabric. Silent on a compacted or resumed session (`source`), which already holds what it was told,
111
- and in a session Fabric launched. How to read the machine's Fabric services is the host instructions' to say."""
112
- if host not in HOSTS or payload.get("source") in QUIET_SOURCES or fabric_launched(env):
425
+ and in a session Fabric launched. How to read the machine's Fabric services is the host instructions' to say.
426
+ A session started on a board request (FAA-201) gets the exit contract instead, launched or not, whatever the folder
427
+ shows."""
428
+ if host not in HOSTS or payload.get("source") in QUIET_SOURCES:
429
+ return None
430
+ dispatch = dispatch_from_env(env)
431
+ if dispatch is not None: # FAA-201: before the launch check, so a run shown in Fabric's console is told too
432
+ prune_dispatch_records(env) # Codex and Kimi have no turn or end hook to do it; the spools stay theirs
433
+ if not dispatch_complete(dispatch):
434
+ return _host_output(host, "SessionStart", DISPATCH_INCOMPLETE.format(workflow=dispatch["workflow"]))
435
+ session_id = payload.get("session_id")
436
+ # a /clear in the run's own process keeps the run its prompt confirmed; a clear with no record holds nothing
437
+ if payload.get("source") == "clear" and isinstance(session_id, str) and SESSION_ID.fullmatch(session_id) \
438
+ and dispatch_holder(dispatch, session_id, env, take_over=True):
439
+ return _host_output(host, "SessionStart", dispatch_contract(dispatch) + dispatch_cleared(
440
+ dispatch_cleared_from(dispatch, env), dispatch["handoff"], session_id))
441
+ # a clear that holds nothing while another session holds the record (on Windows, or a nested session's
442
+ # clear): its prompt hook answers nested whatever its line says, so it is told so now (0.63.0 unit reading 22)
443
+ if payload.get("source") == "clear" and dispatch_held_elsewhere(dispatch, session_id, env):
444
+ return _host_output(host, "SessionStart", DISPATCH_NESTED.format(**dispatch))
445
+ # the prompt is not seen yet: the run is the session whose own first line names these three
446
+ return _host_output(host, "SessionStart", dispatch_contract(dispatch, session_id, conditional=True))
447
+ if fabric_launched(env):
113
448
  return None
114
449
  cwd = Path(payload.get("cwd") or os.getcwd())
115
450
  registration = claude_registration(cwd, env) if host == "claude" else None
@@ -156,12 +491,32 @@ def _unread_line(directory: Path) -> Optional[str]:
156
491
 
157
492
  def hook_user_prompt_submit(payload: Dict[str, Any], env: Optional[Dict[str, str]] = None) -> Optional[str]:
158
493
  """The unread line when the board's count rose since the last one, then each nudge a turn's answer carried
159
- (FAA-142), once; None when there is neither. Reads small files in the session's own folder; never calls Fabric."""
494
+ (FAA-142), once; None when there is neither. Reads small files in the session's own folder; never calls Fabric.
495
+ In a session under FABRIC_DISPATCH_* (FAA-201), the verdict the prompt's first line gives: the run when it names the
496
+ same three as the environment (the first such session holds it), no run when it disagrees."""
160
497
  session_id = payload.get("session_id")
161
- if not isinstance(session_id, str) or not SESSION_ID.fullmatch(session_id) or fabric_launched(env):
498
+ if not isinstance(session_id, str) or not SESSION_ID.fullmatch(session_id):
162
499
  return None
500
+ told = dispatch_from_env(env)
501
+ dispatch, disagree = dispatch_of(env, payload.get("prompt"))
502
+ lines: List[str] = []
503
+ if disagree and not dispatch_complete(told): # the environment lacks them: say that, not a disagreement
504
+ lines = [DISPATCH_INCOMPLETE.format(workflow=told["workflow"])]
505
+ elif disagree: # a line that names other values: no run (Fabric's spec 3.5)
506
+ lines = [DISPATCH_OTHER.format(fields=", ".join(disagree))]
507
+ elif dispatch is not None: # the line names what the environment names: the first such session is the run
508
+ if not dispatch_holder(dispatch, session_id, env):
509
+ lines = [DISPATCH_NESTED.format(workflow=dispatch["workflow"])]
510
+ else:
511
+ previous = dispatch_cleared_from(dispatch, env)
512
+ lines = [DISPATCH_IS_RUN + DISPATCH_SESSION.format(session=session_id) if _SESSION_UUID.fullmatch(
513
+ session_id) else DISPATCH_IS_RUN]
514
+ if previous is not None: # a cleared holder: the accept keeps the old id
515
+ lines = [DISPATCH_IS_RUN + dispatch_cleared(previous, dispatch["handoff"], session_id)]
516
+ if fabric_launched(env) or told is not None: # a dispatched run answers its one request: no unread line, no nudges
517
+ return _host_output("claude", "UserPromptSubmit", "\n".join(lines)) if lines else None
163
518
  directory = session_dir(session_id, env)
164
- lines = [line for line in [_unread_line(directory)] if line] + [nudge_shown(line) for line in take_nudges(directory)]
519
+ lines += [line for line in [_unread_line(directory)] if line] + [nudge_shown(line) for line in take_nudges(directory)]
165
520
  if not lines:
166
521
  return None
167
522
  return json.dumps({"hookSpecificOutput": {"hookEventName": "UserPromptSubmit",
@@ -178,9 +533,9 @@ def hook_spool(payload: Dict[str, Any], event: str, env: Optional[Dict[str, str]
178
533
  session_id = payload.get("session_id")
179
534
  if not isinstance(session_id, str) or not SESSION_ID.fullmatch(session_id):
180
535
  return "skipped: no session id"
536
+ prune_stale_sessions(env, keep=session_id) # before the launch check: "always prune" holds in a launched run too
181
537
  if fabric_launched(env):
182
538
  return "skipped: launched by Fabric"
183
- prune_stale_sessions(env, keep=session_id)
184
539
  hub = locate_hub(fabric_root(env=env))
185
540
  if hub["state"] != "running":
186
541
  return "inert: Fabric is not running"
@@ -11,7 +11,7 @@ import time
11
11
  from pathlib import Path
12
12
  from typing import Any
13
13
  from typing import Dict
14
- from typing import List
14
+ from typing import Callable, List, Optional, Tuple
15
15
 
16
16
 
17
17
  HUB_PROTOCOL = "fabric-hub/0.1"
@@ -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.62.0"
61
+ VERSION = "0.63.0"
62
62
 
63
63
 
64
64
  HOSTS = {"claude": "Claude Code", "codex": "Codex", "kimi": "Kimi Code",
@@ -77,12 +77,21 @@ LAUNCH_MARKER = "FABRIC_LAUNCHED_SESSION"
77
77
  LAUNCH_SIGNALS = ("FABRIC_ACP_SESSION", "FABRIC_BRIDGE_URL", "KILO_CONFIG_CONTENT")
78
78
 
79
79
 
80
+ #: FAA-201: the workflow id a launcher names in FABRIC_DISPATCH_WORKFLOW (Observatory's WORKFLOW_ID); the one copy the
81
+ #: session modules read. dashboard_kit.py keeps its own (it is a kit module) and a test holds the two equal.
82
+ DISPATCH_WORKFLOW = r"wf_[0-9a-f]{16}"
83
+
84
+
80
85
  #: Files that make a folder a Fabric project: the provider manifest and the contract pin adapt_project.py writes, and
81
86
  #: a fabric-package/1 folder's manifest.
82
87
  FABRIC_MARKERS = ("fabric-agent.json", "fabric-contract.lock.json", "fabric.package.json")
83
88
 
84
89
 
85
- UNREAD_LINE = "%d unread on the project board: com.list"
90
+ #: FAA-205 (the operator's rule of 2026-11-04, relayed on the board, thread 11646774): what waits on the Project gets
91
+ #: an answer in the same turn, its state now, the result later in a second message. The line keeps its prefix, which
92
+ #: watchers match on.
93
+ UNREAD_LINE = ("%d unread on the project board: com.list; answer what waits on you in this turn: its state now (done, "
94
+ "where, when, what blocks), the result later")
86
95
 
87
96
  #: FAA-142 (Fabric 0.3.14, ask dfb43ffb): a turn's `fabric_session_observe` answer may carry `nudges`, each
88
97
  #: {kind, says, task_id?, request_id?}, shown on a turn once (at most five) and kept for fabric_work_list. The worker keeps their `says` here and the prompt hook prints
@@ -574,14 +583,14 @@ def spool(directory: Path, entry: Dict[str, Any]) -> Path:
574
583
  def prune_stale_sessions(env: Optional[Dict[str, str]] = None, now: Optional[float] = None,
575
584
  keep: Optional[str] = None) -> int:
576
585
  """Remove session folders whose newest file is older than SESSION_STALE_S (a running worker's lock is a fresh file,
577
- so its folder is never stale); never the folder of the session `keep`. Returns how many were removed. Never
578
- raises."""
586
+ so its folder is never stale), and dispatch records as old (FAA-201); never the folder of the session `keep`.
587
+ Returns how many were removed. Never raises."""
579
588
  now = time.time() if now is None else now
580
589
  removed = 0
581
590
  try:
582
591
  folders = list((state_root(env=env) / "sessions").iterdir())
583
592
  except OSError:
584
- return 0
593
+ folders = []
585
594
  for folder in folders:
586
595
  try:
587
596
  if folder.name == keep or not SESSION_ID.fullmatch(folder.name) or not folder.is_dir():
@@ -592,6 +601,26 @@ def prune_stale_sessions(env: Optional[Dict[str, str]] = None, now: Optional[flo
592
601
  removed += 1
593
602
  except OSError:
594
603
  continue
604
+ return removed + prune_dispatch_records(env, now)
605
+
606
+
607
+ def prune_dispatch_records(env: Optional[Dict[str, str]] = None, now: Optional[float] = None) -> int:
608
+ """FAA-201: remove the dispatch records (which session holds a dispatch) older than SESSION_STALE_S, and the
609
+ temporary file of a write a full disk cut short; nothing else. Returns how many. Never raises."""
610
+ now = time.time() if now is None else now
611
+ removed = 0
612
+ try:
613
+ folder = state_root(env=env) / "dispatch"
614
+ records = list(folder.glob("*.json")) + list(folder.glob(".*.tmp")) # a write a full disk cut short
615
+ except OSError:
616
+ records = []
617
+ for record in records:
618
+ try:
619
+ if now - record.stat().st_mtime > SESSION_STALE_S:
620
+ record.unlink()
621
+ removed += 1
622
+ except OSError:
623
+ continue
595
624
  return removed
596
625
 
597
626
 
@@ -9,6 +9,7 @@ import re
9
9
  import sys
10
10
  import time
11
11
  from pathlib import Path
12
+ from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
12
13
 
13
14
  from fabric_session_env import ( # noqa: E402 - the kit (FAA-65)
14
15
  CONFIG_CAP,
@@ -300,7 +301,9 @@ def install_host_hooks_cli(argv: Sequence[str], env: Optional[Dict[str, str]] =
300
301
  out("Apply it, on the person's go-ahead: fabric_session.py install-host-hooks --host %s --apply "
301
302
  "--expect-sha256 %s" % (args.host, digest))
302
303
  out("The hook only prints the preamble at session start (no turn observation, no unread count: %s "
303
- "cannot rejoin its Fabric session yet). It is silent in a session Fabric launched.%s" % (
304
+ "cannot rejoin its Fabric session yet), or the exit contract in a session Fabric started on a board "
305
+ "request, where it also records which session holds that request (its session id and, after a /clear, the one it replaced, the agent's process id and a time, 0600, in "
306
+ "the user's cache folder) and prunes such records after 7 days; otherwise it is silent in a session Fabric launched.%s" % (
304
307
  HOSTS[args.host], " Codex may ask you to trust the new hook (/hooks) before it runs."
305
308
  if args.host == "codex" else ""))
306
309
  return 0
@@ -4,12 +4,14 @@ from __future__ import annotations
4
4
 
5
5
  import json
6
6
  import os
7
+ import re
7
8
  import sys
8
9
  import time
9
10
  from pathlib import Path
10
- from typing import Any, Dict, Optional, Tuple
11
+ from typing import Any, Callable, Dict, Optional, Sequence, Tuple
11
12
 
12
13
  from fabric_session_env import ( # noqa: E402 - the kit (FAA-65)
14
+ DISPATCH_WORKFLOW,
13
15
  BOARD_STATUS_TOOL,
14
16
  HOOK_FEATURE,
15
17
  SESSION_ID,
@@ -179,6 +181,11 @@ def watch_mail_cli(argv: Sequence[str], env: Optional[Dict[str, str]] = None) ->
179
181
  if fabric_launched(env):
180
182
  sys.stderr.write("watch-mail: this session was launched by Fabric; call com.status with your own tools.\n")
181
183
  return 2
184
+ if re.fullmatch(DISPATCH_WORKFLOW, env.get("FABRIC_DISPATCH_WORKFLOW") or ""):
185
+ sys.stderr.write("watch-mail: this session runs under a board request's environment (its run or a helper "
186
+ "of it); watch-mail does not start here (working-with-fabric "
187
+ "references/on-demand-session.md).\n")
188
+ return 2
182
189
  cwd = Path(args.cwd).expanduser()
183
190
  if not claude_registration(cwd, env):
184
191
  sys.stderr.write("watch-mail: Claude Code has no MCP server named fabric for %s; see working-with-fabric.\n" % cwd)