@junghanacs/entwurf 0.12.6 → 0.12.7
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/AGENTS.md +31 -17
- package/BASELINE.md +42 -8
- package/CHANGELOG.md +22 -0
- package/DELIVERY.md +71 -14
- package/README.md +77 -45
- package/VERIFY.md +18 -13
- package/docs/setup-clean-host.md +80 -22
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +90 -66
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/acp-client.js +54 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +153 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/config.js +436 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/context.js +157 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/engraving.js +105 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +90 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +194 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +153 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +42 -9
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +49 -13
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +104 -11
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +30 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-native-push.js +57 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +10 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +9 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +21 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +5 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +17 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +125 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/adapter.js +158 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/register.js +61 -0
- package/mcp/entwurf-bridge/dist/scripts/agy-imprint.js +166 -0
- package/mcp/entwurf-bridge/dist/scripts/doctor-pi-provider.js +130 -0
- package/mcp/entwurf-bridge/dist/scripts/meta-bridge-prune.js +178 -0
- package/mcp/entwurf-bridge/dist/scripts/new-session-id.js +24 -0
- package/mcp/entwurf-bridge/src/index.ts +101 -67
- package/mcp/entwurf-bridge/test.sh +1 -1
- package/mcp/entwurf-bridge/tsconfig.build.json +23 -3
- package/package.json +10 -5
- package/pi-extensions/lib/entwurf-deliverability.ts +62 -9
- package/pi-extensions/lib/entwurf-self-address.ts +58 -15
- package/pi-extensions/lib/entwurf-v2-contract.ts +120 -12
- package/pi-extensions/lib/entwurf-v2-decider.ts +60 -0
- package/pi-extensions/lib/entwurf-v2-native-push.ts +86 -0
- package/pi-extensions/lib/entwurf-v2-production.ts +20 -0
- package/pi-extensions/lib/entwurf-v2-release.ts +9 -0
- package/pi-extensions/lib/entwurf-v2-runner.ts +29 -1
- package/pi-extensions/lib/entwurf-v2-send.ts +7 -0
- package/pi-extensions/lib/entwurf-v2-surface.ts +17 -0
- package/pi-extensions/lib/meta-sender-identity.ts +154 -0
- package/pi-extensions/lib/native-push/adapter.ts +255 -0
- package/pi-extensions/lib/native-push/register.ts +99 -0
- package/run.sh +756 -214
- package/scripts/agy-bridge-config.py +446 -0
- package/scripts/agy-bridge.sh +359 -0
- package/scripts/agy-hooks-bridge.sh +193 -0
- package/scripts/agy-hooks-config.py +257 -0
- package/scripts/agy-imprint.sh +28 -0
- package/scripts/agy-imprint.ts +193 -0
- package/scripts/agy-statusline-bridge.sh +176 -0
- package/scripts/agy-statusline-config.py +213 -0
- package/scripts/agy-statusline.sh +256 -0
- package/scripts/build-bridge.sh +20 -0
- package/scripts/check-agy-sender-identity.ts +364 -0
- package/scripts/check-entwurf-bridge-boot.ts +8 -2
- package/scripts/check-entwurf-deliverability.ts +34 -0
- package/scripts/check-entwurf-self-address.ts +78 -11
- package/scripts/check-entwurf-v2-contract.ts +136 -1
- package/scripts/check-entwurf-v2-decider.ts +95 -1
- package/scripts/check-entwurf-v2-matrix.ts +14 -3
- package/scripts/check-entwurf-v2-native-push.ts +193 -0
- package/scripts/check-entwurf-v2-production.ts +68 -1
- package/scripts/check-entwurf-v2-runner.ts +58 -0
- package/scripts/check-entwurf-v2-surface.ts +35 -0
- package/scripts/check-install-surface.ts +357 -0
- package/scripts/check-native-push-adapter.ts +319 -0
- package/scripts/check-native-push-register.ts +130 -0
- package/scripts/dev-bin.sh +195 -0
- package/scripts/doctor-pi-provider.ts +140 -0
- package/scripts/meta-bridge-doctor.sh +36 -2
- package/scripts/register-pi-package.py +37 -3
- package/scripts/register-pi-provider.py +287 -0
- package/scripts/smoke-agy-hooks-state.sh +172 -0
- package/scripts/smoke-agy-install-state.sh +660 -0
- package/scripts/smoke-agy-native-push-live.ts +243 -0
- package/scripts/smoke-agy-statusline-state.sh +300 -0
- package/scripts/smoke-meta-async-drift.sh +9 -2
- package/scripts/smoke-meta-install-state.sh +20 -0
- package/scripts/smoke-pi-provider-state.sh +182 -0
- package/scripts/smoke-user-scope-citizen.sh +62 -0
- package/scripts/with-dist-lock.sh +81 -0
- package/scripts/__pycache__/meta-bridge-state.cpython-312.pyc +0 -0
- package/scripts/__pycache__/meta-bridge-state.cpython-313.pyc +0 -0
- package/scripts/__pycache__/register-pi-package.cpython-313.pyc +0 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""agy-hooks-config — JSON state ops for Antigravity hooks.json imprint wiring.
|
|
3
|
+
|
|
4
|
+
Owns one top-level named hook, `entwurf-agy-imprint`, preserving every other
|
|
5
|
+
hook. The command is the bare stable bin `entwurf-agy-imprint`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
|
|
13
|
+
HOOK_KEY = "entwurf-agy-imprint"
|
|
14
|
+
LEGACY_HOOK_KEYS = (HOOK_KEY, "agy-birth-probe")
|
|
15
|
+
EVENT = "PreInvocation"
|
|
16
|
+
OUR_COMMAND = "entwurf-agy-imprint"
|
|
17
|
+
STATE_SCHEMA_VERSION = 1
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def die(code: int, msg: str) -> None:
|
|
21
|
+
sys.stderr.write(msg.rstrip("\n") + "\n")
|
|
22
|
+
sys.exit(code)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def atomic_write(path: str, text: str) -> None:
|
|
26
|
+
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
|
|
27
|
+
tmp = f"{path}.tmp-{os.getpid()}"
|
|
28
|
+
with open(tmp, "w", encoding="utf-8") as fh:
|
|
29
|
+
fh.write(text)
|
|
30
|
+
os.replace(tmp, path)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def load_json(path: str) -> dict:
|
|
34
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
35
|
+
raw = fh.read()
|
|
36
|
+
if raw.strip() == "":
|
|
37
|
+
return {}
|
|
38
|
+
try:
|
|
39
|
+
data = json.loads(raw)
|
|
40
|
+
except json.JSONDecodeError as err:
|
|
41
|
+
die(4, f"agy-hooks: {path} is not valid JSON: {err}")
|
|
42
|
+
if not isinstance(data, dict):
|
|
43
|
+
die(4, f"agy-hooks: {path} top-level must be a JSON object")
|
|
44
|
+
return data
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def dump(data: dict) -> str:
|
|
48
|
+
return json.dumps(data, indent=2) + "\n"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def now() -> str:
|
|
52
|
+
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def hook_value(command: str) -> dict:
|
|
56
|
+
return {EVENT: [{"type": "command", "command": command}]}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def hook_command(value) -> str | None:
|
|
60
|
+
if not isinstance(value, dict):
|
|
61
|
+
return None
|
|
62
|
+
handlers = value.get(EVENT)
|
|
63
|
+
if not isinstance(handlers, list):
|
|
64
|
+
return None
|
|
65
|
+
for item in handlers:
|
|
66
|
+
if isinstance(item, dict) and item.get("type") == "command" and isinstance(item.get("command"), str):
|
|
67
|
+
return item["command"]
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def prior_state(state_path: str, hooks_path: str) -> dict:
|
|
72
|
+
"""The state of OUR FIRST install of this exact hooks file, if we are re-installing over it.
|
|
73
|
+
|
|
74
|
+
Empty when there is no prior install, or when it managed a DIFFERENT path (a re-target is a
|
|
75
|
+
fresh install for that path). See the note in cmd_install for why this exists.
|
|
76
|
+
"""
|
|
77
|
+
if not os.path.exists(state_path):
|
|
78
|
+
return {}
|
|
79
|
+
prior = load_json(state_path)
|
|
80
|
+
if prior.get("managedHooksPath") != os.path.abspath(hooks_path):
|
|
81
|
+
return {}
|
|
82
|
+
return prior
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def cmd_install(hooks_path: str, command: str, state_path: str) -> None:
|
|
86
|
+
if os.path.islink(hooks_path):
|
|
87
|
+
die(3, f"agy-hooks: refusing to adopt {hooks_path} — it is a symlink to {os.readlink(hooks_path)} (someone else's SSOT).")
|
|
88
|
+
existed = os.path.exists(hooks_path)
|
|
89
|
+
if existed:
|
|
90
|
+
data = load_json(hooks_path)
|
|
91
|
+
mode = "adopt-regular-file"
|
|
92
|
+
else:
|
|
93
|
+
data = {}
|
|
94
|
+
mode = "created-new"
|
|
95
|
+
# Capture the preimage ONLY on the first install of this target. An installer is re-run on every
|
|
96
|
+
# upgrade; re-capturing here would record OUR OWN previous hook as "what was there before", and
|
|
97
|
+
# uninstall would then faithfully restore us — leaving behind the exact thing it exists to
|
|
98
|
+
# remove. Provenance is a fact about a moment that has passed: record it once, carry it forward.
|
|
99
|
+
prior = prior_state(state_path, hooks_path)
|
|
100
|
+
if prior:
|
|
101
|
+
preimage = prior.get("preimage", None)
|
|
102
|
+
mode = prior.get("detectMode", mode)
|
|
103
|
+
existed = prior.get("hooksExistedBefore", existed)
|
|
104
|
+
else:
|
|
105
|
+
preimage = data.get(HOOK_KEY, None)
|
|
106
|
+
data[HOOK_KEY] = hook_value(command)
|
|
107
|
+
atomic_write(hooks_path, dump(data))
|
|
108
|
+
state = {
|
|
109
|
+
"schemaVersion": STATE_SCHEMA_VERSION,
|
|
110
|
+
"managedHooksPath": os.path.abspath(hooks_path),
|
|
111
|
+
"hookKey": HOOK_KEY,
|
|
112
|
+
"event": EVENT,
|
|
113
|
+
"command": command,
|
|
114
|
+
"detectMode": mode,
|
|
115
|
+
"hooksExistedBefore": existed,
|
|
116
|
+
"preimage": preimage,
|
|
117
|
+
"installedAt": now(),
|
|
118
|
+
}
|
|
119
|
+
atomic_write(state_path, dump(state))
|
|
120
|
+
sys.stdout.write(f"{mode} {os.path.abspath(hooks_path)}\n")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def cmd_uninstall(state_path: str) -> None:
|
|
124
|
+
if not os.path.exists(state_path):
|
|
125
|
+
die(2, f"agy-hooks: no install-state at {state_path} — nothing to uninstall")
|
|
126
|
+
state = load_json(state_path)
|
|
127
|
+
hooks_path = state.get("managedHooksPath")
|
|
128
|
+
if not isinstance(hooks_path, str):
|
|
129
|
+
die(4, f"agy-hooks: install-state {state_path} has no managedHooksPath")
|
|
130
|
+
if os.path.islink(hooks_path):
|
|
131
|
+
die(3, f"agy-hooks: refusing to uninstall — {hooks_path} became a symlink since install")
|
|
132
|
+
if os.path.exists(hooks_path):
|
|
133
|
+
data = load_json(hooks_path)
|
|
134
|
+
preimage = state.get("preimage", None)
|
|
135
|
+
if preimage is None:
|
|
136
|
+
data.pop(HOOK_KEY, None)
|
|
137
|
+
else:
|
|
138
|
+
data[HOOK_KEY] = preimage
|
|
139
|
+
created_new = state.get("detectMode") == "created-new" and state.get("hooksExistedBefore") is False
|
|
140
|
+
if created_new and len(data) == 0:
|
|
141
|
+
os.remove(hooks_path)
|
|
142
|
+
else:
|
|
143
|
+
atomic_write(hooks_path, dump(data))
|
|
144
|
+
os.remove(state_path)
|
|
145
|
+
sys.stdout.write(f"uninstalled {hooks_path}\n")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def same_path(left: str, right: str) -> bool:
|
|
149
|
+
try:
|
|
150
|
+
return os.path.realpath(left) == os.path.realpath(right)
|
|
151
|
+
except Exception:
|
|
152
|
+
return os.path.abspath(left) == os.path.abspath(right)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def legacy_owned_keys(data: dict) -> list[str]:
|
|
156
|
+
return [key for key in LEGACY_HOOK_KEYS if key in data]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def cmd_cleanup_legacy(legacy_path: str, active_path: str) -> None:
|
|
160
|
+
if same_path(legacy_path, active_path):
|
|
161
|
+
sys.stdout.write("same-path\n")
|
|
162
|
+
return
|
|
163
|
+
if not os.path.lexists(legacy_path):
|
|
164
|
+
sys.stdout.write("absent\n")
|
|
165
|
+
return
|
|
166
|
+
if os.path.islink(legacy_path):
|
|
167
|
+
die(3, f"agy-hooks: refusing to clean legacy {legacy_path} — it is a symlink to {os.readlink(legacy_path)} (someone else's SSOT).")
|
|
168
|
+
data = load_json(legacy_path)
|
|
169
|
+
removed = legacy_owned_keys(data)
|
|
170
|
+
if not removed:
|
|
171
|
+
sys.stdout.write("clean\n")
|
|
172
|
+
return
|
|
173
|
+
for key in removed:
|
|
174
|
+
data.pop(key, None)
|
|
175
|
+
if data:
|
|
176
|
+
atomic_write(legacy_path, dump(data))
|
|
177
|
+
else:
|
|
178
|
+
os.remove(legacy_path)
|
|
179
|
+
sys.stdout.write(f"removed {','.join(removed)}\n")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def cmd_doctor_legacy(legacy_path: str, active_path: str) -> None:
|
|
183
|
+
if same_path(legacy_path, active_path):
|
|
184
|
+
sys.stdout.write("same-path\n")
|
|
185
|
+
return
|
|
186
|
+
if not os.path.lexists(legacy_path):
|
|
187
|
+
sys.stdout.write("absent\n")
|
|
188
|
+
return
|
|
189
|
+
if os.path.islink(legacy_path):
|
|
190
|
+
sys.stdout.write(f"symlink {os.readlink(legacy_path)}\n")
|
|
191
|
+
return
|
|
192
|
+
try:
|
|
193
|
+
data = load_json(legacy_path)
|
|
194
|
+
except SystemExit:
|
|
195
|
+
sys.stdout.write("invalid-json\n")
|
|
196
|
+
return
|
|
197
|
+
owned = legacy_owned_keys(data)
|
|
198
|
+
if owned:
|
|
199
|
+
sys.stdout.write(f"owned {','.join(owned)}\n")
|
|
200
|
+
return
|
|
201
|
+
sys.stdout.write("clean\n")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def cmd_doctor_static(hooks_path: str) -> None:
|
|
205
|
+
real = os.path.realpath(hooks_path)
|
|
206
|
+
if not os.path.exists(real):
|
|
207
|
+
sys.stdout.write("file-absent\n")
|
|
208
|
+
return
|
|
209
|
+
try:
|
|
210
|
+
with open(real, "r", encoding="utf-8") as fh:
|
|
211
|
+
data = json.loads(fh.read() or "{}")
|
|
212
|
+
except (json.JSONDecodeError, OSError):
|
|
213
|
+
sys.stdout.write("invalid-json\n")
|
|
214
|
+
return
|
|
215
|
+
if not isinstance(data, dict):
|
|
216
|
+
sys.stdout.write("invalid-json\n")
|
|
217
|
+
return
|
|
218
|
+
value = data.get(HOOK_KEY)
|
|
219
|
+
cmd = hook_command(value)
|
|
220
|
+
if not cmd:
|
|
221
|
+
sys.stdout.write("not-configured\n")
|
|
222
|
+
return
|
|
223
|
+
if cmd != OUR_COMMAND:
|
|
224
|
+
sys.stdout.write("not-ours\n")
|
|
225
|
+
return
|
|
226
|
+
sys.stdout.write(f"configured {cmd}\n")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def main(argv: list[str]) -> None:
|
|
230
|
+
if len(argv) < 2:
|
|
231
|
+
die(5, "usage: agy-hooks-config.py <install|uninstall|doctor-static> ...")
|
|
232
|
+
if argv[1] == "install":
|
|
233
|
+
if len(argv) != 5:
|
|
234
|
+
die(5, "usage: agy-hooks-config.py install <hooks_path> <command> <state_path>")
|
|
235
|
+
cmd_install(argv[2], argv[3], argv[4])
|
|
236
|
+
elif argv[1] == "uninstall":
|
|
237
|
+
if len(argv) != 3:
|
|
238
|
+
die(5, "usage: agy-hooks-config.py uninstall <state_path>")
|
|
239
|
+
cmd_uninstall(argv[2])
|
|
240
|
+
elif argv[1] == "doctor-static":
|
|
241
|
+
if len(argv) != 3:
|
|
242
|
+
die(5, "usage: agy-hooks-config.py doctor-static <hooks_path>")
|
|
243
|
+
cmd_doctor_static(argv[2])
|
|
244
|
+
elif argv[1] == "cleanup-legacy":
|
|
245
|
+
if len(argv) != 4:
|
|
246
|
+
die(5, "usage: agy-hooks-config.py cleanup-legacy <legacy_hooks_path> <active_hooks_path>")
|
|
247
|
+
cmd_cleanup_legacy(argv[2], argv[3])
|
|
248
|
+
elif argv[1] == "doctor-legacy":
|
|
249
|
+
if len(argv) != 4:
|
|
250
|
+
die(5, "usage: agy-hooks-config.py doctor-legacy <legacy_hooks_path> <active_hooks_path>")
|
|
251
|
+
cmd_doctor_legacy(argv[2], argv[3])
|
|
252
|
+
else:
|
|
253
|
+
die(5, f"agy-hooks-config.py: unknown subcommand {argv[1]!r}")
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
if __name__ == "__main__":
|
|
257
|
+
main(sys.argv)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Stable bin wrapper for Antigravity PreInvocation birth imprint.
|
|
3
|
+
set -euo pipefail
|
|
4
|
+
SELF="${BASH_SOURCE[0]}"
|
|
5
|
+
if command -v readlink >/dev/null 2>&1; then
|
|
6
|
+
RESOLVED="$(readlink -f "$SELF" 2>/dev/null || printf '%s' "$SELF")"
|
|
7
|
+
else
|
|
8
|
+
RESOLVED="$SELF"
|
|
9
|
+
fi
|
|
10
|
+
HERE="$(cd "$(dirname "$RESOLVED")" && pwd)"
|
|
11
|
+
ROOT="$(cd "$HERE/.." && pwd)"
|
|
12
|
+
|
|
13
|
+
# Node refuses --experimental-strip-types for raw .ts below node_modules. Dev
|
|
14
|
+
# clones keep the transparent source path; installed npm/pnpm bins must use the
|
|
15
|
+
# prepack-emitted JS closure, exactly like entwurf-bridge and meta-bridge hooks.
|
|
16
|
+
case "$ROOT" in
|
|
17
|
+
*/node_modules/*)
|
|
18
|
+
DIST="$ROOT/mcp/entwurf-bridge/dist/scripts/agy-imprint.js"
|
|
19
|
+
if [ ! -f "$DIST" ]; then
|
|
20
|
+
echo "entwurf-agy-imprint: installed package is missing prebuilt $DIST" >&2
|
|
21
|
+
exit 1
|
|
22
|
+
fi
|
|
23
|
+
exec node "$DIST"
|
|
24
|
+
;;
|
|
25
|
+
*)
|
|
26
|
+
exec node --experimental-strip-types "$HERE/agy-imprint.ts"
|
|
27
|
+
;;
|
|
28
|
+
esac
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* entwurf-agy-imprint — Antigravity PreInvocation birth hook.
|
|
4
|
+
*
|
|
5
|
+
* Reads agy's camelCase PreInvocation payload from stdin, idempotently upserts an
|
|
6
|
+
* antigravity meta-session by conversationId, writes the sender marker that lets the
|
|
7
|
+
* entwurf-bridge MCP child name this conversation as the caller, and ALWAYS prints exactly
|
|
8
|
+
* the PreInvocation neutral response so the agy loop keeps running.
|
|
9
|
+
*
|
|
10
|
+
* Still deliberately thin: no transcript hydration, no cwd guessing from process.cwd(), and
|
|
11
|
+
* NO receiver marker — that is a mailbox atom, and agy is a native-push citizen with no
|
|
12
|
+
* mailbox (보정①). The record body is the authority; the sender marker is only a pid→garden
|
|
13
|
+
* hint the bridge re-validates against it.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import * as fs from "node:fs";
|
|
17
|
+
import * as os from "node:os";
|
|
18
|
+
import * as path from "node:path";
|
|
19
|
+
import {
|
|
20
|
+
parentPid,
|
|
21
|
+
processStartKey,
|
|
22
|
+
upsertMetaSession,
|
|
23
|
+
writeMetaSenderMarker,
|
|
24
|
+
} from "../pi-extensions/lib/meta-session.ts";
|
|
25
|
+
|
|
26
|
+
const NEUTRAL_RESPONSE = '{"injectSteps":[]}';
|
|
27
|
+
|
|
28
|
+
type Payload = {
|
|
29
|
+
conversationId?: unknown;
|
|
30
|
+
workspacePaths?: unknown;
|
|
31
|
+
transcriptPath?: unknown;
|
|
32
|
+
modelName?: unknown;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function logPath(): string {
|
|
36
|
+
const root = process.env.XDG_STATE_HOME || path.join(os.homedir(), ".local", "state");
|
|
37
|
+
return path.join(root, "entwurf", "agy-imprint.log");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function logLine(message: string): void {
|
|
41
|
+
try {
|
|
42
|
+
const file = logPath();
|
|
43
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
44
|
+
fs.appendFileSync(file, `${new Date().toISOString()} ${message}\n`, { encoding: "utf8", mode: 0o600 });
|
|
45
|
+
} catch {
|
|
46
|
+
// Logging is best-effort; never break agy's PreInvocation loop.
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* OPT-IN owner-topology trace (`ENTWURF_AGY_TRACE_OWNER=1`).
|
|
52
|
+
*
|
|
53
|
+
* The whole sender lane rests on one measured fact: this hook's parent IS the process that also
|
|
54
|
+
* parents the MCP child (both are the agy host). When a send from a birthed agy still arrives
|
|
55
|
+
* anonymous, this trace is the only thing that can say WHY — a plugin host in between, a per-turn
|
|
56
|
+
* worker, a shell wrapper. Compare it with the live bridge's own ancestry (`/proc/<pid>/stat`).
|
|
57
|
+
*
|
|
58
|
+
* OFF by default, and it must stay off: PreInvocation runs before EVERY model turn, so leaving it
|
|
59
|
+
* on would append a line and walk /proc six times per turn, forever — an unbounded trace in a log
|
|
60
|
+
* whose job is to hold birth evidence. The normal log already records the marker write (pid → gid),
|
|
61
|
+
* which is what a healthy host needs.
|
|
62
|
+
*/
|
|
63
|
+
function comm(pid: number): string {
|
|
64
|
+
try {
|
|
65
|
+
return fs.readFileSync(`/proc/${pid}/comm`, "utf8").trim() || "?";
|
|
66
|
+
} catch {
|
|
67
|
+
return "?";
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function ancestry(startPid: number, depth = 6): string {
|
|
72
|
+
const steps: string[] = [];
|
|
73
|
+
let pid: number | null = startPid;
|
|
74
|
+
for (let i = 0; i < depth && typeof pid === "number" && pid > 1; i++) {
|
|
75
|
+
steps.push(`${pid}:${comm(pid)}:${processStartKey(pid) || "-"}`);
|
|
76
|
+
pid = parentPid(pid);
|
|
77
|
+
}
|
|
78
|
+
return steps.join(" < ");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function traceOwnerTopology(conversationId: string): void {
|
|
82
|
+
if (process.env.ENTWURF_AGY_TRACE_OWNER !== "1") return;
|
|
83
|
+
logLine(
|
|
84
|
+
`pids conversationId=${conversationId} hookPid=${process.pid} ppid=${process.ppid} chain=${ancestry(process.pid)}`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function readStdin(): string {
|
|
89
|
+
try {
|
|
90
|
+
return fs.readFileSync(0, "utf8");
|
|
91
|
+
} catch (err) {
|
|
92
|
+
logLine(`read-stdin-failed ${err instanceof Error ? err.message : String(err)}`);
|
|
93
|
+
return "";
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function firstWorkspace(value: unknown): string | null {
|
|
98
|
+
if (!Array.isArray(value)) return null;
|
|
99
|
+
const first = value[0];
|
|
100
|
+
return typeof first === "string" && first.trim() ? first : null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function optionalString(value: unknown): string | null {
|
|
104
|
+
return typeof value === "string" && value.trim() ? value : null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function imprint(raw: string): void {
|
|
108
|
+
let payload: Payload;
|
|
109
|
+
try {
|
|
110
|
+
const parsed = JSON.parse(raw || "{}");
|
|
111
|
+
payload = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Payload) : {};
|
|
112
|
+
} catch (err) {
|
|
113
|
+
logLine(`skip invalid-json ${err instanceof Error ? err.message : String(err)}`);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const conversationId = optionalString(payload.conversationId);
|
|
118
|
+
if (!conversationId) {
|
|
119
|
+
logLine("skip missing-conversationId");
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const cwd = firstWorkspace(payload.workspacePaths);
|
|
124
|
+
if (!cwd) {
|
|
125
|
+
logLine(`skip missing-workspacePaths conversationId=${conversationId}`);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
traceOwnerTopology(conversationId);
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
const result = upsertMetaSession({
|
|
133
|
+
input: {
|
|
134
|
+
backend: "antigravity",
|
|
135
|
+
nativeSessionId: conversationId,
|
|
136
|
+
cwd,
|
|
137
|
+
model: optionalString(payload.modelName),
|
|
138
|
+
transcriptPath: optionalString(payload.transcriptPath),
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
logLine(`ok ${result.action} gardenId=${result.record.gardenId} conversationId=${conversationId} cwd=${cwd}`);
|
|
142
|
+
|
|
143
|
+
// Sender marker — what lets the entwurf-bridge MCP child name WHO is calling it. The
|
|
144
|
+
// bridge and this hook are both children of the same agy process (measured: hook.ppid ==
|
|
145
|
+
// bridge.ppid == the `agy` pid, identical start-key), so that pid is the join key; the
|
|
146
|
+
// bridge looks a marker up under its own parent, finds this one, and the send goes out as
|
|
147
|
+
// a replyable garden citizen instead of anonymous external-mcp.
|
|
148
|
+
//
|
|
149
|
+
// The marker binds the pid to the conversation it is CURRENTLY invoking — not to one
|
|
150
|
+
// conversation forever. PreInvocation runs synchronously before every model turn, so a
|
|
151
|
+
// `/new` (same pid, new conversationId) rebinds the marker before that conversation can
|
|
152
|
+
// make its first tool call. Overwriting is a binding refresh, not an identity mutation:
|
|
153
|
+
// both conversations keep their meta-records.
|
|
154
|
+
//
|
|
155
|
+
// THE LIMIT, stated plainly: one pid has exactly ONE marker file per backend
|
|
156
|
+
// (`<senders>/antigravity/<pid>.json`). Two conversations invoking CONCURRENTLY under one
|
|
157
|
+
// agy process would therefore NOT leave two markers for the bridge to refuse — the second
|
|
158
|
+
// hook write silently overwrites the first, and a tool call still in flight from the first
|
|
159
|
+
// would be attributed to the second. Nothing downstream can detect that. So this binding
|
|
160
|
+
// rests on a runtime invariant: an agy process serializes its model invocations (its hook
|
|
161
|
+
// runs synchronously before each turn). Same-process concurrency is NOT supported, and
|
|
162
|
+
// there is no fail-closed guard for it — if agy ever gains it, the pid stops identifying
|
|
163
|
+
// the caller and the join key must change.
|
|
164
|
+
//
|
|
165
|
+
// Written only after the upsert above: the record store is the identity authority, and a
|
|
166
|
+
// marker pointing at a garden-id with no record would be a window of un-backed identity.
|
|
167
|
+
// A failed marker costs reply-addressability, never the session — log and move on.
|
|
168
|
+
const ownerPid = process.ppid;
|
|
169
|
+
if (typeof ownerPid === "number" && ownerPid > 0) {
|
|
170
|
+
try {
|
|
171
|
+
writeMetaSenderMarker({
|
|
172
|
+
backend: "antigravity",
|
|
173
|
+
gardenId: result.record.gardenId,
|
|
174
|
+
nativeSessionId: conversationId,
|
|
175
|
+
cwd,
|
|
176
|
+
ownerPid,
|
|
177
|
+
});
|
|
178
|
+
logLine(`sender marker ${ownerPid} -> ${result.record.gardenId} conversationId=${conversationId}`);
|
|
179
|
+
} catch (err) {
|
|
180
|
+
logLine(
|
|
181
|
+
`sender-marker-failed pid=${ownerPid} gardenId=${result.record.gardenId} ${err instanceof Error ? err.message : String(err)}`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
} catch (err) {
|
|
186
|
+
logLine(
|
|
187
|
+
`upsert-failed conversationId=${conversationId} ${err instanceof Error ? err.stack || err.message : String(err)}`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
imprint(readStdin());
|
|
193
|
+
process.stdout.write(`${NEUTRAL_RESPONSE}\n`);
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# agy-statusline-bridge — install/uninstall/doctor for the agy (Antigravity) statusLine ownership
|
|
3
|
+
# (#46 Task 1). Sets agy settings.statusLine to entwurf's stable-bin renderer so the ambient
|
|
4
|
+
# status area shows driver + garden id (the claude meta-bridge statusLine symmetry, mirrored as a
|
|
5
|
+
# SEPARATE agy adapter — 봉인 7). SEPARATE state from agy-bridge (mcp_config) and dev-bin.
|
|
6
|
+
#
|
|
7
|
+
# install own the WHOLE statusLine subtree of agy settings.json → {type:custom,
|
|
8
|
+
# command:entwurf-agy-statusline, enabled:true} (adopt a regular file / create /
|
|
9
|
+
# REFUSE a symlink), recording an install-state for an honest inverse. The command
|
|
10
|
+
# is a BARE stable bin — dev resolves it via `expose-dev-bin`, installed via the npm
|
|
11
|
+
# bin-link. NEVER a repo/checkout path (#46 tripwire).
|
|
12
|
+
# uninstall honest inverse from the install-state (restore the captured statusLine preimage).
|
|
13
|
+
# doctor STATIC: settings.json (the SINGLE root agy reads statusLine from — proven by the
|
|
14
|
+
# Task-1 capture, NOT the 2-candidate mcp_config root) resolves, parses, and carries
|
|
15
|
+
# OUR command that RESOLVES. LIVE: only when an agy process exists, else honest SKIP.
|
|
16
|
+
#
|
|
17
|
+
# Paths (all overridable for the isolated smoke):
|
|
18
|
+
# AGY_SETTINGS_CONFIG install target (default: ~/.gemini/antigravity-cli/settings.json)
|
|
19
|
+
# AGY_STATUSLINE_COMMAND command to register (default: entwurf-agy-statusline — a stable bin)
|
|
20
|
+
# XDG_DATA_HOME install-state root (state at $XDG_DATA_HOME/entwurf/agy-statusline/)
|
|
21
|
+
set -euo pipefail
|
|
22
|
+
|
|
23
|
+
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
24
|
+
CONFIG_PY="$HERE/agy-statusline-config.py"
|
|
25
|
+
|
|
26
|
+
SETTINGS="${AGY_SETTINGS_CONFIG:-$HOME/.gemini/antigravity-cli/settings.json}"
|
|
27
|
+
COMMAND="${AGY_STATUSLINE_COMMAND:-entwurf-agy-statusline}"
|
|
28
|
+
STATE_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/entwurf/agy-statusline"
|
|
29
|
+
STATE_FILE="$STATE_DIR/install-state.json"
|
|
30
|
+
|
|
31
|
+
log() { printf '%s\n' "$*"; }
|
|
32
|
+
fail() { printf 'FAIL: %s\n' "$*" >&2; exit 1; }
|
|
33
|
+
|
|
34
|
+
# Is COMMAND runnable? NOTE: this checks OUR shell's PATH. The honest question is "does the bin
|
|
35
|
+
# resolve in the environment AGY runs in" — agy lives at ~/.local/bin/agy, so a dev-bin link in
|
|
36
|
+
# ~/.local/bin (agy's own dir) naturally shares that PATH. This check is the best local proxy; a
|
|
37
|
+
# PATH divergence between our shell and agy's would still be a real (rare) gap. A bare name is
|
|
38
|
+
# looked up on PATH; an absolute/relative path must be an executable file.
|
|
39
|
+
command_resolvable() {
|
|
40
|
+
local cmd="$1"
|
|
41
|
+
case "$cmd" in
|
|
42
|
+
*/*) [ -x "$cmd" ] ;;
|
|
43
|
+
*) command -v "$cmd" >/dev/null 2>&1 ;;
|
|
44
|
+
esac
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
do_install() {
|
|
48
|
+
log "[agy-statusline install]"
|
|
49
|
+
log " target: $SETTINGS"
|
|
50
|
+
log " command: $COMMAND"
|
|
51
|
+
log " state: $STATE_FILE"
|
|
52
|
+
if ! command_resolvable "$COMMAND"; then
|
|
53
|
+
log " note: '$COMMAND' is not currently resolvable on this host — install still records it"
|
|
54
|
+
log " (expose-dev-bin/npm bin-link provides it; doctor will FAIL until it is on PATH)."
|
|
55
|
+
fi
|
|
56
|
+
local out rc
|
|
57
|
+
set +e
|
|
58
|
+
out="$(python3 "$CONFIG_PY" install "$SETTINGS" "$COMMAND" "$STATE_FILE" 2>&1)"
|
|
59
|
+
rc=$?
|
|
60
|
+
set -e
|
|
61
|
+
case "$rc" in
|
|
62
|
+
0) log " ok: ${out}" ;;
|
|
63
|
+
3) fail "refused (symlink) — ${out}" ;;
|
|
64
|
+
4) fail "invalid JSON — ${out}" ;;
|
|
65
|
+
*) fail "install error (rc=$rc) — ${out}" ;;
|
|
66
|
+
esac
|
|
67
|
+
log " installed. Verify with: ./run.sh doctor-agy-statusline"
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
do_uninstall() {
|
|
71
|
+
log "[agy-statusline uninstall]"
|
|
72
|
+
log " state: $STATE_FILE"
|
|
73
|
+
local out rc
|
|
74
|
+
set +e
|
|
75
|
+
out="$(python3 "$CONFIG_PY" uninstall "$STATE_FILE" 2>&1)"
|
|
76
|
+
rc=$?
|
|
77
|
+
set -e
|
|
78
|
+
case "$rc" in
|
|
79
|
+
0) log " ok: ${out}" ;;
|
|
80
|
+
2) log " note: ${out}" ;; # no state → nothing to undo (idempotent)
|
|
81
|
+
3) fail "refused (symlink) — ${out}" ;;
|
|
82
|
+
*) fail "uninstall error (rc=$rc) — ${out}" ;;
|
|
83
|
+
esac
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
do_doctor() {
|
|
87
|
+
log "[agy-statusline doctor]"
|
|
88
|
+
local hard_fail=0 runtime_ready=0 link_note=""
|
|
89
|
+
if [ -L "$SETTINGS" ]; then
|
|
90
|
+
link_note=" [symlink → $(readlink "$SETTINGS")]"
|
|
91
|
+
fi
|
|
92
|
+
|
|
93
|
+
log "── static (settings root: the single root agy reads statusLine from)"
|
|
94
|
+
local status
|
|
95
|
+
status="$(python3 "$CONFIG_PY" doctor-static "$SETTINGS")"
|
|
96
|
+
case "$status" in
|
|
97
|
+
file-absent) log " settings: file absent$link_note" ;;
|
|
98
|
+
absent) log " settings: statusLine absent$link_note" ;;
|
|
99
|
+
invalid-json) log " settings: INVALID JSON$link_note"; hard_fail=1 ;;
|
|
100
|
+
not-ours) log " settings: statusLine present but NOT entwurf-owned (still the prior/agent-config command)$link_note" ;;
|
|
101
|
+
configured\ *)
|
|
102
|
+
local cmd="${status#configured }"
|
|
103
|
+
if command_resolvable "$cmd"; then
|
|
104
|
+
runtime_ready=1
|
|
105
|
+
log " settings: configured → '$cmd' (resolvable)$link_note"
|
|
106
|
+
else
|
|
107
|
+
log " settings: configured → '$cmd' DANGLING (not on PATH / not executable — run expose-dev-bin)$link_note"
|
|
108
|
+
hard_fail=1
|
|
109
|
+
fi ;;
|
|
110
|
+
*) log " settings: unexpected status '$status'$link_note"; hard_fail=1 ;;
|
|
111
|
+
esac
|
|
112
|
+
|
|
113
|
+
# state-evidence (mirrors agy-bridge N1): install-state present ⇒ its managed settings MUST
|
|
114
|
+
# still configure OUR command. state ∧ not-configured = DRIFT (installed, then removed) → FAIL.
|
|
115
|
+
if [ -f "$STATE_FILE" ]; then
|
|
116
|
+
local managed expected_settings
|
|
117
|
+
expected_settings="$(python3 -c 'import os,sys; print(os.path.abspath(sys.argv[1]))' "$SETTINGS")"
|
|
118
|
+
if ! managed="$(python3 -c 'import json,os,sys; v=json.load(open(sys.argv[1])).get("managedSettingsPath"); assert isinstance(v,str) and os.path.isabs(v); print(os.path.abspath(v))' "$STATE_FILE" 2>/dev/null)"; then
|
|
119
|
+
# isabs: install always records an absolute path — a relative one is corrupt, and
|
|
120
|
+
# normalizing it against OUR cwd could bless whatever directory the doctor runs from.
|
|
121
|
+
log " state: CORRUPT — install-state is unreadable or its managedSettingsPath is missing/non-absolute: $STATE_FILE"
|
|
122
|
+
hard_fail=1
|
|
123
|
+
elif [ "$managed" != "$expected_settings" ]; then
|
|
124
|
+
# The state describes a DIFFERENT settings file than the one agy reads here. The live
|
|
125
|
+
# statusLine's provenance (whose command was there before ours) is therefore NOT recorded:
|
|
126
|
+
# uninstalling would drop the key instead of restoring the operator's own command. A test
|
|
127
|
+
# run that isolated HOME but SHARED XDG_DATA_HOME produces exactly this shape.
|
|
128
|
+
log " state: FOREIGN TARGET — install-state manages '$managed', but agy reads '$expected_settings' on this host. The live statusLine has no recorded preimage, so uninstall could not restore what was there before. Re-run install-agy-statusline against this host (and check whether an isolated test leaked its state)."
|
|
129
|
+
hard_fail=1
|
|
130
|
+
else
|
|
131
|
+
local managed_status
|
|
132
|
+
managed_status="$(python3 "$CONFIG_PY" doctor-static "$managed")"
|
|
133
|
+
case "$managed_status" in
|
|
134
|
+
configured\ *) log " state: install-state present; its managed settings still configure entwurf-agy-statusline." ;;
|
|
135
|
+
file-absent)
|
|
136
|
+
log " state: ORPHANED — install-state records $managed but the file is absent (HOME reset). Auto-cleaning stale state."
|
|
137
|
+
rm -f "$STATE_FILE"
|
|
138
|
+
;;
|
|
139
|
+
*) log " state: DRIFT — install-state records $managed but it no longer configures entwurf-agy-statusline (removed since install)."; hard_fail=1 ;;
|
|
140
|
+
esac
|
|
141
|
+
fi
|
|
142
|
+
else
|
|
143
|
+
case "$status" in
|
|
144
|
+
configured\ *) : ;;
|
|
145
|
+
*) log " note: no install-state and statusLine not entwurf-owned (never installed — this is the '?'; run install-agy-statusline)." ;;
|
|
146
|
+
esac
|
|
147
|
+
fi
|
|
148
|
+
|
|
149
|
+
log "── live (runtime wiring)"
|
|
150
|
+
if command -v pgrep >/dev/null 2>&1 && pgrep -x agy >/dev/null 2>&1; then
|
|
151
|
+
if [ "$runtime_ready" -eq 1 ]; then
|
|
152
|
+
if [ "$hard_fail" -eq 0 ]; then
|
|
153
|
+
log " live: agy is running AND statusLine resolves to our renderer — consistent with runtime wiring (statusline read NOT proven; agy re-reads settings on launch)."
|
|
154
|
+
else
|
|
155
|
+
log " live: agy is running and statusLine resolves to our renderer, but ownership/state errors above keep this doctor red (statusline read NOT proven)."
|
|
156
|
+
fi
|
|
157
|
+
else
|
|
158
|
+
log " live: agy is running but statusLine is not our resolvable command — runtime wiring is broken."
|
|
159
|
+
hard_fail=1
|
|
160
|
+
fi
|
|
161
|
+
else
|
|
162
|
+
log " live: SKIP — no agy process (cannot check runtime wiring; NOT a pass)."
|
|
163
|
+
fi
|
|
164
|
+
|
|
165
|
+
if [ "$hard_fail" -ne 0 ]; then
|
|
166
|
+
fail "doctor found a broken statusLine (invalid JSON / dangling command / drift / broken live wiring)."
|
|
167
|
+
fi
|
|
168
|
+
log "doctor: ok."
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
case "${1:-}" in
|
|
172
|
+
install) do_install ;;
|
|
173
|
+
uninstall) do_uninstall ;;
|
|
174
|
+
doctor) do_doctor ;;
|
|
175
|
+
*) echo "usage: agy-statusline-bridge.sh <install|uninstall|doctor>" >&2; exit 2 ;;
|
|
176
|
+
esac
|