@passioncode-ai/fabric-agent-adapter 0.61.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.
- package/CHANGELOG.md +201 -0
- package/README.md +6 -3
- package/SECURITY.md +3 -1
- 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 +4 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +4 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +57 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +11 -9
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +56 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/channel-agent.example.json +103 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +160 -19
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +2 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/migrating-a-service.md +6 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +16 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +18 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +33 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +2 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +10 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +3 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +33 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +14 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +57 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +3 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +18 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +55 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +5 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +57 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +4 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +193 -13
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample-remote-service.mjs +2 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +42 -4
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +2 -2
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-that-answer-people.md +5 -2
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/practices.md +46 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +16 -15
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md +7 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +209 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +34 -6
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +29 -5
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +362 -7
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +35 -6
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +4 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +8 -1
package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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
|
|
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"
|
package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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`.
|
|
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
|
-
|
|
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
|
|
package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py
CHANGED
|
@@ -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)
|
|
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
|
package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py
CHANGED
|
@@ -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)
|