@passioncode-ai/fabric-agent-adapter 0.40.0 → 0.41.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 +124 -0
- package/README.md +9 -3
- package/SECURITY.md +27 -6
- package/bin/fabric-agent-adapter.js +71 -6
- package/package.json +2 -2
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +42 -66
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +4 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +55 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +9 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/verification.md +15 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +181 -26
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +66 -5
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +81 -23
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +28 -30
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +21 -9
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +47 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +230 -29
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +21 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +5 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +3 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +5 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +48 -19
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +66 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +53 -19
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +145 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +196 -188
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +5 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +3 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +48 -14
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +34 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +144 -20
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +7 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +12 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +6 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +17 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +3 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +162 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +207 -227
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +4 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +0 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +52 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +101 -45
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +12 -12
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +5 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +13 -10
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +118 -71
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +272 -74
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +36 -11
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +3 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +130 -35
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +25 -7
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +29 -60
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +4 -2
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +55 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +66 -5
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md +2 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +26 -13
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +6 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +30 -22
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +37 -6
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +72 -13
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +25 -7
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +199 -43
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +10 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/context.mjs +25 -2
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/openclaw/index.mjs +4 -2
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/opencode/fabric.mjs +2 -1
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# The operator channel and the repository standard
|
|
2
|
+
|
|
3
|
+
Shared by `adapting-projects-to-fabric` and `creating-fabric-agents`; the two copies are byte-identical
|
|
4
|
+
(`test/test_shared_references.py`). "The agent" is the provider being adapted or created.
|
|
5
|
+
|
|
6
|
+
## The operator channel
|
|
7
|
+
|
|
8
|
+
If the agent has a human stop (a choice, an approval, a confirmation) or emits `notify: true`
|
|
9
|
+
events, propose its **operator channel** to the operator once, at the step that links here. The
|
|
10
|
+
channel is the agent's own messenger channel, first Telegram, that sends those events and answers
|
|
11
|
+
its stops with buttons: `fabric-operator-channel/0.1` (contract DEC-0034,
|
|
12
|
+
`docs/specification/operator-channel.md`), whose rule OC-11 this follows. Give the operator's four
|
|
13
|
+
steps:
|
|
14
|
+
|
|
15
|
+
1. create a bot with @BotFather;
|
|
16
|
+
2. put its token into the operator's secret store (Project Observatory where it is installed) under
|
|
17
|
+
the name the agent reads (for example `EXAMPLE_AGENT_TELEGRAM_BOT_TOKEN`), never into a chat, a
|
|
18
|
+
file or the bundle;
|
|
19
|
+
3. run the agent's link command;
|
|
20
|
+
4. type the code it shows in the chat with the bot.
|
|
21
|
+
|
|
22
|
+
In a group shared with other bots the channel follows OC-14 to OC-17 (DEC-0035): it stays silent
|
|
23
|
+
unless addressed (`/command@ThisBot`, a reply to its own message or a press of its own button; a
|
|
24
|
+
bare `/command` in a group is not addressed), and a channel nobody receives on is `degraded`
|
|
25
|
+
(`receiver-missing`). Say so in the proposal when the operator means to bind a shared group.
|
|
26
|
+
|
|
27
|
+
Record the answer (`accepted`, `declined` or `later`) in the agent's state and in its conformance
|
|
28
|
+
report, and do not ask again; an agent that serves `surfaces.operatorChannel` also shows it as
|
|
29
|
+
`proposal` in its status document. Creating the bot, storing the token and enabling the channel
|
|
30
|
+
are the operator's action: never create the bot, ask for or store the token's value, or enable the
|
|
31
|
+
channel yourself. The default pin carries DEC-0034 and DEC-0035: a served status document is
|
|
32
|
+
checked against the pinned `schemas/operator-channel-status.schema.json`; the channel's runtime
|
|
33
|
+
rules have no probe rule here yet, so report them `NOT_RUN`. An agent with neither stops nor notify
|
|
34
|
+
events skips this.
|
|
35
|
+
|
|
36
|
+
## The repository standard
|
|
37
|
+
|
|
38
|
+
For a PassionCode.ai repository, the agent's repository follows the organization's
|
|
39
|
+
[repository standard](https://github.com/passioncode-ai/fabric-workspace/blob/main/knowledge/repository-standard.md)
|
|
40
|
+
(`knowledge/repository-standard.md` in the organization's knowledge base; a clone has it at
|
|
41
|
+
`fabric-workspace/knowledge/`) from its first commit:
|
|
42
|
+
|
|
43
|
+
- `LICENSE`, `COMMERCIAL-LICENSE.md` and `CLA.md` copied byte for byte from the knowledge base's
|
|
44
|
+
`templates/`, and `AGPL-3.0-only OR LicenseRef-PassionCode-Commercial` in every manifest and
|
|
45
|
+
every SKILL.md `license:`; `SECURITY.md` when the repository is public;
|
|
46
|
+
- a README whose first heading is the full name, with `## Quick start for a new teammate`
|
|
47
|
+
(Install; Configure, key names only; MCP, the registration command and one proving tool call,
|
|
48
|
+
verified with a real client in a temporary config; Develop) and `## License`;
|
|
49
|
+
- an `AGENTS.md` that opens with the template's *Read first* block and closes with *After work*,
|
|
50
|
+
and a `CLAUDE.md` whose first line is `@AGENTS.md`.
|
|
51
|
+
|
|
52
|
+
org-index `scripts/check_format.py` reports 0 findings for it before it is called done. An agent
|
|
53
|
+
someone builds for themselves is not a PassionCode.ai repository: its licence is its owner's
|
|
54
|
+
choice, nothing about it is published or listed by the organization, and none of these files is
|
|
55
|
+
required of it, though the verified MCP quick start is still how anyone learns to drive it.
|
package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py
CHANGED
|
@@ -7,7 +7,7 @@ This is an output correctness gate, not authentication, discovery, or a relay.
|
|
|
7
7
|
import json
|
|
8
8
|
import re
|
|
9
9
|
import sys
|
|
10
|
-
from urllib.parse import parse_qs, urlsplit
|
|
10
|
+
from urllib.parse import parse_qs, parse_qsl, unquote, urlsplit
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
def require(condition, reason):
|
|
@@ -15,6 +15,63 @@ def require(condition, reason):
|
|
|
15
15
|
raise ValueError(reason)
|
|
16
16
|
|
|
17
17
|
|
|
18
|
+
# Credential names and values: the same rules as fabric-button.js (credentialName, secretValue).
|
|
19
|
+
CREDENTIAL_WORDS = {
|
|
20
|
+
'token', 'secret', 'password', 'passwd', 'pass', 'passphrase', 'credential', 'credentials', 'cookie',
|
|
21
|
+
'authorization', 'bearer', 'jwt', 'otp', 'apikey', 'signature', 'sig', 'accesstoken', 'idtoken', 'authtoken',
|
|
22
|
+
'refreshtoken', 'sessionid', 'sessiontoken', 'logincode', 'authcode', 'privatekey', 'clientsecret', 'secretkey',
|
|
23
|
+
'accesskey'}
|
|
24
|
+
CREDENTIAL_PAIRS = {
|
|
25
|
+
'key': {'private', 'api', 'access', 'secret', 'session', 'auth', 'signing', 'encryption', 'master', 'client', 'login',
|
|
26
|
+
'refresh'},
|
|
27
|
+
'auth': {'token', 'code', 'header'},
|
|
28
|
+
'session': {'token', 'id'},
|
|
29
|
+
'code': {'login', 'auth', 'access', 'session'}}
|
|
30
|
+
JWT_SHAPE = re.compile(r'eyJ[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]*')
|
|
31
|
+
SECRET_VALUE = re.compile(r'(^bearer\s.+|sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|xox[abposr]-[A-Za-z0-9-]{10,}'
|
|
32
|
+
r'|AKIA[0-9A-Z]{16}|glpat-[A-Za-z0-9_-]{20,})', re.I | re.S)
|
|
33
|
+
LONG_RUN = re.compile(r'[A-Za-z0-9+/=_-]{40,}')
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def name_words(name):
|
|
37
|
+
name = re.sub(r'([a-z0-9])([A-Z])', r'\1 \2', name)
|
|
38
|
+
name = re.sub(r'([A-Z]+)([A-Z][a-z])', r'\1 \2', name)
|
|
39
|
+
return [w.lower() for w in re.split(r'[\s._-]+', name) if w]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def credential_name(name):
|
|
43
|
+
split = name_words(name)
|
|
44
|
+
words = set(split) | {w[:-1] for w in split if len(w) > 2 and w.endswith('s')}
|
|
45
|
+
if words & CREDENTIAL_WORDS:
|
|
46
|
+
return True
|
|
47
|
+
if len(split) == 1 and words & set(CREDENTIAL_PAIRS):
|
|
48
|
+
return True
|
|
49
|
+
return any(w in words and words & pairs for w, pairs in CREDENTIAL_PAIRS.items())
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def secret_value(value):
|
|
53
|
+
long_secret = any(re.search(r'[a-z]', run) and re.search(r'[A-Z]', run) and re.search(r'[0-9]', run)
|
|
54
|
+
for run in LONG_RUN.findall(value))
|
|
55
|
+
return bool(JWT_SHAPE.search(value) or SECRET_VALUE.search(value) or long_secret)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def carries_credential(text):
|
|
59
|
+
# A query or fragment whose names or values could be a credential: refused even with an empty value.
|
|
60
|
+
return (secret_value(text) or secret_value(unquote(text))
|
|
61
|
+
or any(credential_name(name) or secret_value(name) or secret_value(value)
|
|
62
|
+
for name, value in parse_qsl(text, keep_blank_values=True)))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def login_path(path):
|
|
66
|
+
# The path a server routes: refuse what normalises differently in different servers, then compare.
|
|
67
|
+
decoded = unquote(path)
|
|
68
|
+
require(not re.search(r'%2f|%5c', path, re.I) and '\\' not in decoded, 'encoded_separator')
|
|
69
|
+
segments = decoded.split('/')[1:]
|
|
70
|
+
require(not any(seg in ('.', '..') for seg in segments), 'dot_segment')
|
|
71
|
+
require(all(seg for seg in segments[:-1]), 'empty_segment')
|
|
72
|
+
return '/'.join([''] + [seg for seg in segments if seg]).lower() == '/fabric/v1/login'
|
|
73
|
+
|
|
74
|
+
|
|
18
75
|
def safe_path(path):
|
|
19
76
|
return (isinstance(path, str) and path.startswith('/') and not path.startswith('//')
|
|
20
77
|
and len(path) <= 2048 and not re.search(r'[\\\x00-\x1f\x7f]', path))
|
|
@@ -42,10 +99,14 @@ def check(data):
|
|
|
42
99
|
and not plain.username and not plain.password, 'invalid_http_fallback')
|
|
43
100
|
http_page = plain.path + ('?' + plain.query if plain.query else '') + ('#' + plain.fragment if plain.fragment else '')
|
|
44
101
|
require(safe_path(http_page) and http_page == (page or '/'), 'mismatched_page')
|
|
45
|
-
sensitive = {'token', 'access_token', 'refresh_token', 'password', 'secret', 'login_code'
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
102
|
+
sensitive = {'token', 'access_token', 'refresh_token', 'password', 'secret', 'login_code', 'code', 'api_key',
|
|
103
|
+
'apikey', 'key', 'auth'}
|
|
104
|
+
parts = urlsplit(http_page)
|
|
105
|
+
places = [parts.query, parts.fragment] + parts.fragment.split('?', 1)[1:]
|
|
106
|
+
require(not any(sensitive.intersection(k.lower() for k in parse_qs(p, keep_blank_values=True)) for p in places),
|
|
107
|
+
'credential_query')
|
|
108
|
+
require(not any(carries_credential(p) for p in places), 'credential_query')
|
|
109
|
+
require(not login_path(parts.path), 'login_link_is_not_dashboard')
|
|
49
110
|
state = host.get('state')
|
|
50
111
|
require(state in ('available', 'not_installed', 'unknown', 'incompatible', 'handler_mismatch', 'unsupported'), 'unknown_host_state')
|
|
51
112
|
target, viewer = data.get('target_device'), data.get('viewer_device')
|
|
@@ -19,7 +19,7 @@ compatibility: >-
|
|
|
19
19
|
this plugin; Codex/Kimi use command hooks; Hermes/OpenCode/OpenClaw/Pi use native context plugins after opt-in.
|
|
20
20
|
metadata:
|
|
21
21
|
author: PassionCode.ai
|
|
22
|
-
version: "0.
|
|
22
|
+
version: "0.41.0"
|
|
23
23
|
---
|
|
24
24
|
|
|
25
25
|
# Working with Fabric
|
|
@@ -225,8 +225,8 @@ and do the work without claiming Fabric saw it.
|
|
|
225
225
|
In Claude Code this plugin ships four: at session start and `/clear` (not on compact or resume) the preamble above,
|
|
226
226
|
or, when Fabric is down and the project uses Fabric, one line pointing at Step 1; after each turn the turn's facts (HEAD, files changed, lines added
|
|
227
227
|
and removed) and the board's unread count; at each prompt one line only when that count rose; at the end, the
|
|
228
|
-
release of what the session left claimed. The turn, prompt and end hooks
|
|
229
|
-
`session-hooks/1
|
|
228
|
+
release of what the session left claimed. The turn, prompt and end hooks spool and call Fabric only when the running
|
|
229
|
+
Fabric advertises `session-hooks/1`; the turn and end hooks always prune their own stale spool. All are silent in a session Fabric launched, never block the session and read no token. Read
|
|
230
230
|
[session hooks](references/session-hooks.md) when a person asks what the hooks do, where they log, how to turn them
|
|
231
231
|
off, or what they need from Fabric.
|
|
232
232
|
|
package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md
CHANGED
|
@@ -26,7 +26,7 @@ python3 <skill-dir>/scripts/fabric_session.py install-host-hooks --host opencode
|
|
|
26
26
|
python3 <skill-dir>/scripts/fabric_session.py install-host-hooks --host opencode --remove
|
|
27
27
|
```
|
|
28
28
|
|
|
29
|
-
Use `codex`, `kimi`, `hermes`, `opencode`, `openclaw` or `pi`. Default is a dry run. `--config` overrides the native destination, `--script` selects an absolute `fabric_session.py` in a **complete installed skill**. Keep that path stable; a versioned plugin cache disappears on updates. Reapply after moving the skill. The native installer checks the helper and every required host module before writing a loader.
|
|
29
|
+
Use `codex`, `kimi`, `hermes`, `opencode`, `openclaw` or `pi`. Default is a dry run. `--config` overrides the native destination, `--script` selects an absolute `fabric_session.py` in a **complete installed skill**. Keep that path stable; a versioned plugin cache disappears on updates. Reapply after moving the skill: an install refuses a `--script` that is not an existing file, and Codex and Kimi Code rewrite their own hook in place to the new path (`updated`), finding a Kimi `[[hooks]]` table by the command it runs, not by its exact text. The native installer checks the helper and every required host module before writing a loader. Removal needs neither: it reads nothing from the skill it was installed from, so it works after that skill moved or lost its `hosts/` folder.
|
|
30
30
|
|
|
31
31
|
| Host | Default destination |
|
|
32
32
|
|---|---|
|
|
@@ -45,6 +45,7 @@ OpenCode and Pi loader files carry a managed marker. A foreign file at the same
|
|
|
45
45
|
|
|
46
46
|
- Native bridges give the Python helper only a working directory and, when observed, a boolean Fabric registration. Hermes uses its native read-only config, OpenCode its resolved enabled MCP entry, OpenClaw its resolved MCP servers and Pi an exact callable `fabric_whoami` tool name. Config registration is not admission.
|
|
47
47
|
- Missing helper/Python, malformed values, excessive output or a four-second deadline produce no context. Existing host errors and permission failures are not silenced globally.
|
|
48
|
+
- OpenCode and OpenClaw ask for context on every request; their bridges keep the helper's answer per workspace and registration for the life of the host process, so Python runs once, not per request. A failed run (an error, a nonzero exit, a timeout, no `python3`) reads as `null`, distinct from an empty answer `''`, and is not kept, so the next request asks again. A Fabric started later is seen by a new host instance.
|
|
48
49
|
- Fabric-launched sessions are silent. Other native events are not advertised: turn observations, unread counts and rejoin-only release remain Claude-specific and feature-gated.
|
|
49
50
|
- A host in Switchboard's catalog may support MCP, proxy or launch while exposing no startup context hook. Never copy this configuration to another host solely because it can launch an agent.
|
|
50
51
|
|
|
@@ -20,7 +20,7 @@ need from Fabric. In Claude Code they live in the plugin's `hooks/hooks.json` an
|
|
|
20
20
|
| Event | When it acts | What it does |
|
|
21
21
|
|---|---|---|
|
|
22
22
|
| SessionStart (`startup`, `clear`; the plugin's matcher, and the script ignores `compact` and `resume` for every host) | Claude Code has an MCP server named `fabric` (local, project or user scope) | Fabric running: adds the preamble: `fabric_whoami` first; `com.status` and `com.list`, `com.read_ack` only what was read; `fabric_work_list`, then `fabric_work_claim`; `fabric_work_release` at the end; ask another Project only through `com.submit`. Fabric down, in a project that uses Fabric (below): one line, skip `fabric_whoami` and the board, start Fabric through the lifecycle broker only if the task needs it. Fabric down anywhere else, or not registered: nothing. |
|
|
23
|
-
| Stop (each turn) | as for SessionStart, and the running Fabric's `hub.json` lists `"session-hooks/1"` in `features` | spools one entry and starts a detached worker that sends `fabric_session_observe {event: "turn", head, filesChanged, linesAdded, linesRemoved, …}
|
|
23
|
+
| Stop (each turn) | as for SessionStart, and the running Fabric's `hub.json` lists `"session-hooks/1"` in `features` | measures the turn's facts (HEAD, files changed, lines added and removed: counts and ids only, git sharing 1.5 s), spools them in one entry and starts a detached worker that sends `fabric_session_observe {event: "turn", head, filesChanged, linesAdded, linesRemoved, …}` as stored, then reads `com.status` and keeps the unread count in the session's spool (`board.json`) |
|
|
24
24
|
| UserPromptSubmit | the session's spool holds a count | adds ONE line, `N unread on the project board: com.list`, only when the count rose since the last line; a fall or the same count says nothing. It reads two small files and never calls Fabric. |
|
|
25
25
|
| SessionEnd | as for Stop | spools the end; the worker sends `fabric_work_release {claims: "session", outcome: "let-go", …}`, then `fabric_session_observe {event: "end", reason}`, and removes the session's spool |
|
|
26
26
|
|
|
@@ -31,8 +31,8 @@ outside a checkout; never above it); or a service descriptor whose `source.repos
|
|
|
31
31
|
`remote.origin.url`. The machine's service descriptors are no longer counted into the session: the host's own
|
|
32
32
|
instructions say how to read Fabric services (since 0.38.0).
|
|
33
33
|
|
|
34
|
-
Without `session-hooks/1`, Stop and SessionEnd read `hub.json`, return, and
|
|
35
|
-
nothing to show; `fabric_session.py status` says "Session hooks: inert". That is every Fabric before the release
|
|
34
|
+
Without `session-hooks/1`, Stop and SessionEnd read `hub.json`, return, and spool nothing (they only remove stale
|
|
35
|
+
session folders, below), so UserPromptSubmit has nothing to show; `fabric_session.py status` says "Session hooks: inert". That is every Fabric before the release
|
|
36
36
|
that ships the dependencies below. A board that cannot be read (`unavailable`, a refusal) leaves no count, never a
|
|
37
37
|
zero; a `degraded` board that still gives the count is read.
|
|
38
38
|
|
|
@@ -48,8 +48,10 @@ session id Fabric keeps with the enrollment, which only Claude Code supplies tod
|
|
|
48
48
|
would add (never a value already in the file) and the `--apply --expect-sha256 <digest>` command. With `--apply` it
|
|
49
49
|
backs the file up (`<file>.<UTC stamp>.bak`, never overwriting an earlier backup), keeps the file's mode, refuses a
|
|
50
50
|
file whose shape is not `{"hooks": {"<Event>": [{"hooks": [...]}]}}` (Codex) or an array of `[[hooks]]` tables
|
|
51
|
-
(Kimi Code, read back with `tomllib`: Python 3.11+), and refuses one that changed since the dry run.
|
|
52
|
-
|
|
51
|
+
(Kimi Code, read back with `tomllib`: Python 3.11+), and refuses one that changed since the dry run. It refuses a
|
|
52
|
+
`--script` that is not an existing file. Our own entry, found by what it runs (for Kimi Code each `[[hooks]]` table is
|
|
53
|
+
read on its own, so a hand-touched one is still found), is rewritten in place to the new script (`updated`) rather
|
|
54
|
+
than added a second time. `--remove` takes the entry out again, stale or not, and needs no script. Codex may ask the person to trust a new hook (`/hooks`) before it runs. Codex keys that trust by event, group and index, so
|
|
53
55
|
`--remove` names the SessionStart groups after the removed one that it moves, which Codex asks the person to approve
|
|
54
56
|
again. The hook runs the
|
|
55
57
|
`fabric_session.py` of the skills install (`~/.agents/skills/working-with-fabric/`) when there is one, else this
|
|
@@ -63,8 +65,10 @@ They consume the same context through their own APIs. They never use the Claude-
|
|
|
63
65
|
`fabric_session.py watch-mail [--interval 60]` is for an idle Claude Code session waiting on the board; the agent
|
|
64
66
|
starts it under a background watcher. It rejoins the session (`CLAUDE_CODE_SESSION_ID`, or `--session-id`) through
|
|
65
67
|
`fabric-mcp --rejoin-only` for each look, prints one line each time the unread count rises, and waits `interval`
|
|
66
|
-
± 20 % between looks (never under 10 s
|
|
67
|
-
|
|
68
|
+
± 20 % between looks (after the jitter never under 10 s and never over 15 minutes). It shares the session's
|
|
69
|
+
once-a-minute rejoin with the Stop worker: a look within a minute of the last rejoin (the worker's `rejoined` mark or
|
|
70
|
+
its own) waits instead, and its own rejoin renews that mark when the session's folder exists. While Fabric is down or
|
|
71
|
+
the board unreadable it says so once and backs off, doubling to at most 15 minutes. It exits on its stdin's end (when started with a pipe), SIGTERM, SIGINT or SIGHUP,
|
|
68
72
|
or when its parent is gone. It refuses to start in a session Fabric launched, and without a `fabric` registration.
|
|
69
73
|
|
|
70
74
|
## A session Fabric launched
|
|
@@ -78,13 +82,18 @@ only variable names are looked at, never a value (two of them carry the session'
|
|
|
78
82
|
`<Fabric's folder>/sessions/` (Codex).
|
|
79
83
|
3. Claude Code launched by Fabric has no variable yet: its command line holds `--mcp-config
|
|
80
84
|
<Fabric's folder>/sessions/<id>/mcp.json`. SessionStart, and Stop once per session, look for that in a few
|
|
81
|
-
ancestor processes (`ps`, bounded to 0.5 s each); only the match is used, nothing is kept
|
|
85
|
+
ancestor processes (`ps`, bounded to 0.5 s each); only the match is used, nothing is logged. A match is kept as
|
|
86
|
+
an empty `launched` file in the session's folder, so the walk runs once and nothing is spooled after it. A `ps`
|
|
87
|
+
that fails or runs out of time is "unknown", never "not launched": that hook spools nothing (so no worker can
|
|
88
|
+
release a launched session's claims) and the next one looks again.
|
|
82
89
|
|
|
83
90
|
## What they never do
|
|
84
91
|
|
|
85
92
|
- **Block or slow the session.** Each hook has a 5 s ceiling in `hooks.json`; the script reads a few small files and
|
|
86
|
-
returns in tens of milliseconds
|
|
87
|
-
|
|
93
|
+
returns in tens of milliseconds, plus the turn's git facts in Stop and SessionEnd (at most 1.5 s, shared by every
|
|
94
|
+
git call). Every call to Fabric happens in a detached worker with a 10 s deadline; at its end the worker closes
|
|
95
|
+
`fabric-mcp`'s stdin and then ends its whole process group, so a wrapper (`npx`, a shell script,
|
|
96
|
+
`fabric-mcp.cmd`) cannot keep it waiting. Every hook exits 0, on every path.
|
|
88
97
|
- **Read a secret.** The hooks never open Fabric's door token or an enrollment credential; `fabric-mcp` holds
|
|
89
98
|
those. Nothing secret is in argv (the worker's argv is the script and a spool folder) or in the log.
|
|
90
99
|
- **Send or read content.** A turn is the HEAD commit, the number of files that differ from it (untracked included)
|
|
@@ -96,8 +105,10 @@ only variable names are looked at, never a value (two of them carry the session'
|
|
|
96
105
|
- **Rejoin more than once a minute.** Every rejoin spends the agent's Fabric request budget, which every session of
|
|
97
106
|
the machine shares. A turn within 60 s of the session's last successful rejoin (the worker marks it as `rejoined`
|
|
98
107
|
in the session's folder) is spooled and waits for the next worker; the end of a session always starts one (FAA-53).
|
|
99
|
-
- **Retry in a loop.** A refused call is logged by its error code and
|
|
100
|
-
|
|
108
|
+
- **Retry in a loop.** A refused call, or one answered with a JSON-RPC error, is logged by its error code and its
|
|
109
|
+
entry dropped; an entry that cannot be read is logged as `outcome=dropped` and dropped. The entries after it, the
|
|
110
|
+
end's release and the end observation still go out. An answer that never came is logged as `outcome=unknown` and
|
|
111
|
+
leaves the spool to the next worker. The calls carry an `idempotencyKey`, so a repeat is the same event.
|
|
101
112
|
|
|
102
113
|
## Where they log, and how to turn them off
|
|
103
114
|
|
|
@@ -106,7 +117,9 @@ One line per event that did something, never content: `~/Library/Caches/ai.passi
|
|
|
106
117
|
`%LOCALAPPDATA%\fabric-agent-adapter\hooks.log` (Windows). Mode 0600, rotated at 1 MB to `hooks.log.1`. The
|
|
107
118
|
per-session spool is `sessions/<session id>/` beside it (turn entries capped at 50, `board.json`, `board.shown`; a
|
|
108
119
|
folder untouched for 7 days, whose worker failed or whose session ended without SessionEnd, is removed by the next
|
|
109
|
-
|
|
120
|
+
Stop or SessionEnd hook, even while the hooks are inert; an end entry whose release or observation got no answer stays
|
|
121
|
+
for the next worker, and the folder goes only once the end is delivered; a stale worker lock is taken by renaming it, never by a
|
|
122
|
+
check and a delete),
|
|
110
123
|
removed at the session's end.
|
|
111
124
|
|
|
112
125
|
Turn them off with Claude Code's own switch (`/hooks`, or `"disableAllHooks": true` in settings), or remove the
|
|
@@ -34,6 +34,8 @@ from fabric_session_env import ( # noqa: F401
|
|
|
34
34
|
LAUNCH_SIGNALS,
|
|
35
35
|
FABRIC_MARKERS,
|
|
36
36
|
UNREAD_LINE,
|
|
37
|
+
LAUNCHED_MARK,
|
|
38
|
+
rejoin_wait,
|
|
37
39
|
_home,
|
|
38
40
|
fabric_root,
|
|
39
41
|
state_root,
|
|
@@ -71,6 +73,10 @@ from fabric_session_mcp import ( # noqa: F401
|
|
|
71
73
|
OBSERVE_TOOL,
|
|
72
74
|
RELEASE_TOOL,
|
|
73
75
|
WAITING_TOOL,
|
|
76
|
+
FACTS_BUDGET_S,
|
|
77
|
+
FACT_KEYS,
|
|
78
|
+
JsonRpcError,
|
|
79
|
+
stored_facts,
|
|
74
80
|
git_facts,
|
|
75
81
|
_count_status,
|
|
76
82
|
McpStdio,
|
package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py
CHANGED
|
@@ -11,9 +11,12 @@ from pathlib import Path
|
|
|
11
11
|
from fabric_session_env import ( # noqa: E402 - the kit (FAA-65)
|
|
12
12
|
HOOK_FEATURE,
|
|
13
13
|
HOSTS,
|
|
14
|
+
LAUNCHED_MARK,
|
|
14
15
|
NATIVE_PLUGIN_HOSTS,
|
|
16
|
+
REJOIN_MIN_INTERVAL_S,
|
|
15
17
|
SESSION_ID,
|
|
16
18
|
UNREAD_LINE,
|
|
19
|
+
_remove_tree,
|
|
17
20
|
_write_private,
|
|
18
21
|
claude_registration,
|
|
19
22
|
codex_registered,
|
|
@@ -29,12 +32,15 @@ from fabric_session_env import ( # noqa: E402 - the kit (FAA-65)
|
|
|
29
32
|
project_uses_fabric,
|
|
30
33
|
prune_stale_sessions,
|
|
31
34
|
read_small,
|
|
35
|
+
rejoined_recently,
|
|
32
36
|
session_dir,
|
|
33
37
|
spawn_worker,
|
|
34
38
|
spool,
|
|
35
39
|
)
|
|
36
40
|
from fabric_session_mcp import ( # noqa: E402 - the kit (FAA-65)
|
|
41
|
+
FACTS_BUDGET_S,
|
|
37
42
|
deliver,
|
|
43
|
+
git_facts,
|
|
38
44
|
)
|
|
39
45
|
from fabric_session_mail import ( # noqa: E402 - the kit (FAA-65)
|
|
40
46
|
watch_mail_cli,
|
|
@@ -44,11 +50,6 @@ from fabric_session_hosts import ( # noqa: E402 - the kit (FAA-65)
|
|
|
44
50
|
)
|
|
45
51
|
|
|
46
52
|
|
|
47
|
-
#: A session rejoins at most once in this span (FAA-53): every rejoin spends Fabric's per-agent request budget, which
|
|
48
|
-
#: every session of the machine shares; a turn inside it waits in the spool for the next worker, an end never waits.
|
|
49
|
-
REJOIN_MIN_INTERVAL_S = 60.0
|
|
50
|
-
|
|
51
|
-
|
|
52
53
|
PREAMBLE = (
|
|
53
54
|
"[fabric-agent-adapter] Fabric is running and this {host} has its MCP server `fabric` registered. "
|
|
54
55
|
"Registration is not proof of connection or admission. "
|
|
@@ -144,15 +145,17 @@ def hook_user_prompt_submit(payload: Dict[str, Any], env: Optional[Dict[str, str
|
|
|
144
145
|
|
|
145
146
|
|
|
146
147
|
def hook_spool(payload: Dict[str, Any], event: str, env: Optional[Dict[str, str]] = None, spawn=spawn_worker) -> str:
|
|
147
|
-
"""Stop (`turn`) and SessionEnd (`end`): spool one entry
|
|
148
|
-
tests. Nothing
|
|
149
|
-
`fabric` is registered
|
|
150
|
-
before the one that ships the hooks' dependency (`status` says so: "Session hooks: inert").
|
|
148
|
+
"""Stop (`turn`) and SessionEnd (`end`): spool one entry, with the turn's facts measured now, and start the worker.
|
|
149
|
+
Returns what happened, for the tests. Nothing is spooled and no worker runs unless the running Fabric advertises
|
|
150
|
+
`session-hooks/1` and `fabric` is registered: hub.json is read first because it is the cheap check that fails for
|
|
151
|
+
every Fabric before the one that ships the hooks' dependency (`status` says so: "Session hooks: inert"). Stale
|
|
152
|
+
session folders are pruned before that check, so an inert Fabric does not keep them (FAA-68)."""
|
|
151
153
|
session_id = payload.get("session_id")
|
|
152
154
|
if not isinstance(session_id, str) or not SESSION_ID.fullmatch(session_id):
|
|
153
155
|
return "skipped: no session id"
|
|
154
156
|
if fabric_launched(env):
|
|
155
157
|
return "skipped: launched by Fabric"
|
|
158
|
+
prune_stale_sessions(env, keep=session_id)
|
|
156
159
|
hub = locate_hub(fabric_root(env=env))
|
|
157
160
|
if hub["state"] != "running":
|
|
158
161
|
return "inert: Fabric is not running"
|
|
@@ -162,12 +165,26 @@ def hook_spool(payload: Dict[str, Any], event: str, env: Optional[Dict[str, str]
|
|
|
162
165
|
if not claude_registration(cwd, env):
|
|
163
166
|
return "skipped: fabric is not registered"
|
|
164
167
|
directory = session_dir(session_id, env)
|
|
165
|
-
|
|
166
|
-
|
|
168
|
+
if (directory / LAUNCHED_MARK).exists():
|
|
169
|
+
if event == "end":
|
|
170
|
+
_remove_tree(directory)
|
|
167
171
|
return "skipped: launched by Fabric"
|
|
172
|
+
# The ancestor check costs a process-table read or two: only once per session, when its folder is first made. A
|
|
173
|
+
# positive answer is kept in the folder; an unknown one (a `ps` that failed) spools nothing, so no worker can
|
|
174
|
+
# release a launched session's claims, and is asked again at the next hook.
|
|
175
|
+
if not directory.is_dir():
|
|
176
|
+
launched = launched_by_ancestor(env)
|
|
177
|
+
if launched is None:
|
|
178
|
+
return "skipped: whether Fabric launched this session is unknown"
|
|
179
|
+
if launched:
|
|
180
|
+
if event != "end":
|
|
181
|
+
directory.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
182
|
+
_write_private(directory / LAUNCHED_MARK, "")
|
|
183
|
+
return "skipped: launched by Fabric"
|
|
184
|
+
# The turn's own facts, measured when it ended (counts and ids only, never a path), not when a worker runs.
|
|
168
185
|
entry = {"event": event, "runtimeSessionId": session_id, "cwd": str(cwd),
|
|
169
|
-
"observedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
170
|
-
|
|
186
|
+
"observedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
187
|
+
"facts": git_facts(cwd, budget_s=FACTS_BUDGET_S)}
|
|
171
188
|
if event == "end" and isinstance(payload.get("reason"), str):
|
|
172
189
|
entry["reason"] = payload["reason"][:64]
|
|
173
190
|
spool(directory, entry)
|
|
@@ -179,15 +196,6 @@ def hook_spool(payload: Dict[str, Any], event: str, env: Optional[Dict[str, str]
|
|
|
179
196
|
return "spooled: worker started"
|
|
180
197
|
|
|
181
198
|
|
|
182
|
-
def rejoined_recently(directory: Path, now: Optional[float] = None) -> bool:
|
|
183
|
-
"""Whether this session's last rejoin succeeded within REJOIN_MIN_INTERVAL_S (the worker marks it)."""
|
|
184
|
-
try:
|
|
185
|
-
mark = (directory / "rejoined").stat().st_mtime
|
|
186
|
-
except OSError:
|
|
187
|
-
return False
|
|
188
|
-
return 0 <= (time.time() if now is None else now) - mark < REJOIN_MIN_INTERVAL_S
|
|
189
|
-
|
|
190
|
-
|
|
191
199
|
def status(cwd: Path, env: Optional[Dict[str, str]] = None, platform: str = sys.platform) -> Dict[str, Any]:
|
|
192
200
|
root = fabric_root(platform, env)
|
|
193
201
|
binary = find_fabric_bin(platform, env)
|
package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py
CHANGED
|
@@ -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.
|
|
60
|
+
VERSION = "0.41.0"
|
|
61
61
|
|
|
62
62
|
|
|
63
63
|
HOSTS = {"claude": "Claude Code", "codex": "Codex", "kimi": "Kimi Code",
|
|
@@ -401,24 +401,55 @@ 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
|
|
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.
|
|
407
|
-
|
|
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 =
|
|
420
|
+
command, parent = process(pid)
|
|
414
421
|
if command is None:
|
|
415
|
-
return
|
|
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
|
+
def rejoined_recently(directory: Path, now: Optional[float] = None) -> bool:
|
|
439
|
+
"""Whether this session's last rejoin succeeded within REJOIN_MIN_INTERVAL_S (the worker marks it)."""
|
|
440
|
+
return rejoin_wait(directory, now) > 0
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def rejoin_wait(directory: Path, now: Optional[float] = None) -> float:
|
|
444
|
+
"""Seconds until this session may rejoin again; 0 when it may now."""
|
|
445
|
+
try:
|
|
446
|
+
mark = (directory / "rejoined").stat().st_mtime
|
|
447
|
+
except OSError:
|
|
448
|
+
return 0.0
|
|
449
|
+
age = (time.time() if now is None else now) - mark
|
|
450
|
+
return REJOIN_MIN_INTERVAL_S - age if 0 <= age < REJOIN_MIN_INTERVAL_S else 0.0
|
|
451
|
+
|
|
452
|
+
|
|
422
453
|
def log(line: str, env: Optional[Dict[str, str]] = None) -> None:
|
|
423
454
|
"""One structured line, 0600, rotated at 1 MB. Never raises."""
|
|
424
455
|
try:
|
package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
|
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
|
-
|
|
222
|
-
|
|
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
|
-
|
|
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"))
|