@junghanacs/entwurf 0.12.5 → 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 +44 -0
- package/DELIVERY.md +71 -14
- package/README.md +77 -45
- package/VERIFY.md +33 -38
- 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 -6
- 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 +974 -248
- 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 +47 -9
- package/scripts/meta-bridge-install.sh +20 -17
- package/scripts/meta-bridge-state.py +40 -2
- package/scripts/meta-bridge-uninstall.sh +23 -12
- package/scripts/register-pi-package.py +183 -0
- 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 +178 -8
- package/scripts/smoke-pi-provider-state.sh +182 -0
- package/scripts/smoke-user-scope-citizen.sh +191 -0
- package/scripts/with-dist-lock.sh +81 -0
- package/scripts/__pycache__/meta-bridge-state.cpython-312.pyc +0 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""agy-statusline-config — JSON state ops for the agy (Antigravity) statusLine install adapter
|
|
3
|
+
(#46 Task 1). SEPARATE from agy-bridge (mcp_config) and from the Claude meta-bridge; only the
|
|
4
|
+
reporting/runner shell is shared. stdlib only.
|
|
5
|
+
|
|
6
|
+
Owns the WHOLE `statusLine` subtree of agy's settings.json (봉인계약 4 statusLine판 + GPT G5-2:
|
|
7
|
+
statusLine.command partial-ownership collides with whole-subtree ownership). The command written
|
|
8
|
+
is ALWAYS the bare stable bin `entwurf-agy-statusline` (dev AND installed — #46 tripwire; the
|
|
9
|
+
checkout path lives only in the dev-bin symlink state, never in this settings file).
|
|
10
|
+
|
|
11
|
+
Subcommands (argv[1]):
|
|
12
|
+
install <settings_path> <command> <state_path>
|
|
13
|
+
Adopt agy settings.json and set statusLine = {type:"custom", command:<command>,
|
|
14
|
+
enabled:true} (the WHOLE subtree). REFUSES a symlink target (exit 3 — someone else's SSOT,
|
|
15
|
+
e.g. an agent-config link). The prior statusLine subtree (or None if absent) is captured as
|
|
16
|
+
the preimage for an honest inverse. Unrelated keys (model/permissions/…) are preserved.
|
|
17
|
+
Absent file is created. Invalid JSON fails loud (exit 4). Writes install-state atomically.
|
|
18
|
+
|
|
19
|
+
uninstall <state_path>
|
|
20
|
+
Honest inverse: restore the captured statusLine preimage (remove the key if it was absent,
|
|
21
|
+
else set it back). If WE created the whole file and nothing else remains, remove it. REFUSES
|
|
22
|
+
if the settings file became a symlink since install (exit 3). No state → nothing (exit 2).
|
|
23
|
+
|
|
24
|
+
doctor-static <settings_path>
|
|
25
|
+
Print one shell-parseable status token line: `file-absent` / `absent` (file exists but
|
|
26
|
+
no statusLine command) / `invalid-json` / `not-ours` (statusLine present but command !=
|
|
27
|
+
entwurf-agy-statusline) / `configured <command>`. Never mutates.
|
|
28
|
+
|
|
29
|
+
Exit codes: 0 ok · 2 no-state · 3 refuse-symlink · 4 invalid-json · 5 usage.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
import json
|
|
33
|
+
import os
|
|
34
|
+
import sys
|
|
35
|
+
|
|
36
|
+
KEY = "statusLine"
|
|
37
|
+
OUR_COMMAND = "entwurf-agy-statusline"
|
|
38
|
+
STATE_SCHEMA_VERSION = 1
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _die(code: int, msg: str) -> "None":
|
|
42
|
+
sys.stderr.write(msg.rstrip("\n") + "\n")
|
|
43
|
+
sys.exit(code)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _atomic_write(path: str, text: str) -> None:
|
|
47
|
+
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
|
|
48
|
+
tmp = f"{path}.tmp-{os.getpid()}"
|
|
49
|
+
with open(tmp, "w", encoding="utf-8") as fh:
|
|
50
|
+
fh.write(text)
|
|
51
|
+
os.replace(tmp, path)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _load(path: str) -> dict:
|
|
55
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
56
|
+
raw = fh.read()
|
|
57
|
+
if raw.strip() == "":
|
|
58
|
+
return {}
|
|
59
|
+
try:
|
|
60
|
+
data = json.loads(raw)
|
|
61
|
+
except json.JSONDecodeError as err:
|
|
62
|
+
_die(4, f"agy-statusline: {path} is not valid JSON: {err}")
|
|
63
|
+
if not isinstance(data, dict):
|
|
64
|
+
_die(4, f"agy-statusline: {path} top-level must be a JSON object")
|
|
65
|
+
return data
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _dump(data: dict) -> str:
|
|
69
|
+
return json.dumps(data, indent=2) + "\n"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _now() -> str:
|
|
73
|
+
import datetime
|
|
74
|
+
|
|
75
|
+
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _prior_state(state_path: str, settings_path: str) -> dict:
|
|
79
|
+
"""The state of OUR FIRST install of this exact settings file, if we are re-installing over it.
|
|
80
|
+
|
|
81
|
+
Empty dict when there is no prior install, or when the prior install managed a DIFFERENT path
|
|
82
|
+
(a re-target is a fresh install for that path, and its preimage must be captured anew).
|
|
83
|
+
"""
|
|
84
|
+
if not os.path.exists(state_path):
|
|
85
|
+
return {}
|
|
86
|
+
prior = _load(state_path)
|
|
87
|
+
if prior.get("managedSettingsPath") != os.path.abspath(settings_path):
|
|
88
|
+
return {}
|
|
89
|
+
return prior
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def cmd_install(settings_path: str, command: str, state_path: str) -> None:
|
|
93
|
+
# REFUSE a symlink — someone else's SSOT (an agent-config link). Never clobber.
|
|
94
|
+
if os.path.islink(settings_path):
|
|
95
|
+
target = os.readlink(settings_path)
|
|
96
|
+
_die(3, f"agy-statusline: refusing to adopt {settings_path} — it is a symlink to {target} "
|
|
97
|
+
f"(someone else's SSOT). Manage it there, or replace it with a regular file, then retry.")
|
|
98
|
+
|
|
99
|
+
settings_existed = os.path.exists(settings_path)
|
|
100
|
+
if settings_existed:
|
|
101
|
+
data = _load(settings_path)
|
|
102
|
+
detect_mode = "adopt-regular-file"
|
|
103
|
+
else:
|
|
104
|
+
data = {}
|
|
105
|
+
detect_mode = "created-new"
|
|
106
|
+
|
|
107
|
+
# Capture the WHOLE prior statusLine subtree (None = absent) for the honest inverse — but ONLY
|
|
108
|
+
# on the first install of this target. An installer is re-run routinely (every package upgrade);
|
|
109
|
+
# re-capturing here would capture OUR OWN previous subtree as "what was there before", and
|
|
110
|
+
# uninstall would then faithfully restore us — leaving behind the exact thing it exists to
|
|
111
|
+
# remove. Provenance is a fact about a moment that has already passed: record it once, carry it.
|
|
112
|
+
prior = _prior_state(state_path, settings_path)
|
|
113
|
+
if prior:
|
|
114
|
+
preimage = prior.get("preimage", None)
|
|
115
|
+
detect_mode = prior.get("detectMode", detect_mode)
|
|
116
|
+
settings_existed = prior.get("settingsExistedBefore", settings_existed)
|
|
117
|
+
else:
|
|
118
|
+
preimage = data.get(KEY, None)
|
|
119
|
+
data[KEY] = {"type": "custom", "command": command, "enabled": True}
|
|
120
|
+
|
|
121
|
+
_atomic_write(settings_path, _dump(data))
|
|
122
|
+
|
|
123
|
+
state = {
|
|
124
|
+
"schemaVersion": STATE_SCHEMA_VERSION,
|
|
125
|
+
"managedSettingsPath": os.path.abspath(settings_path),
|
|
126
|
+
"key": KEY,
|
|
127
|
+
"command": command,
|
|
128
|
+
"detectMode": detect_mode,
|
|
129
|
+
"settingsExistedBefore": settings_existed,
|
|
130
|
+
"preimage": preimage, # null = statusLine was absent before install
|
|
131
|
+
"installedAt": _now(),
|
|
132
|
+
}
|
|
133
|
+
_atomic_write(state_path, _dump(state))
|
|
134
|
+
sys.stdout.write(f"{detect_mode} {os.path.abspath(settings_path)}\n")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def cmd_uninstall(state_path: str) -> None:
|
|
138
|
+
if not os.path.exists(state_path):
|
|
139
|
+
_die(2, f"agy-statusline: no install-state at {state_path} — nothing to uninstall "
|
|
140
|
+
f"(never installed, or already uninstalled).")
|
|
141
|
+
state = _load(state_path)
|
|
142
|
+
settings_path = state.get("managedSettingsPath")
|
|
143
|
+
if not isinstance(settings_path, str):
|
|
144
|
+
_die(4, f"agy-statusline: install-state {state_path} has no managedSettingsPath")
|
|
145
|
+
|
|
146
|
+
if os.path.islink(settings_path):
|
|
147
|
+
_die(3, f"agy-statusline: refusing to uninstall — {settings_path} became a symlink since "
|
|
148
|
+
f"install (someone else's SSOT now). Resolve by hand.")
|
|
149
|
+
|
|
150
|
+
if os.path.exists(settings_path):
|
|
151
|
+
data = _load(settings_path)
|
|
152
|
+
preimage = state.get("preimage", None)
|
|
153
|
+
if preimage is None:
|
|
154
|
+
data.pop(KEY, None)
|
|
155
|
+
else:
|
|
156
|
+
data[KEY] = preimage
|
|
157
|
+
# If WE created the whole file and nothing else remains, remove it (honest inverse).
|
|
158
|
+
created_new = state.get("detectMode") == "created-new" and state.get("settingsExistedBefore") is False
|
|
159
|
+
only_ours_empty = created_new and len(data) == 0
|
|
160
|
+
if only_ours_empty:
|
|
161
|
+
os.remove(settings_path)
|
|
162
|
+
else:
|
|
163
|
+
_atomic_write(settings_path, _dump(data))
|
|
164
|
+
|
|
165
|
+
os.remove(state_path)
|
|
166
|
+
sys.stdout.write(f"uninstalled {settings_path}\n")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def cmd_doctor_static(settings_path: str) -> None:
|
|
170
|
+
real = os.path.realpath(settings_path)
|
|
171
|
+
if not os.path.exists(real):
|
|
172
|
+
sys.stdout.write("file-absent\n")
|
|
173
|
+
return
|
|
174
|
+
try:
|
|
175
|
+
with open(real, "r", encoding="utf-8") as fh:
|
|
176
|
+
data = json.loads(fh.read() or "{}")
|
|
177
|
+
except (json.JSONDecodeError, OSError):
|
|
178
|
+
sys.stdout.write("invalid-json\n")
|
|
179
|
+
return
|
|
180
|
+
sl = data.get(KEY) if isinstance(data, dict) else None
|
|
181
|
+
cmd = sl.get("command") if isinstance(sl, dict) else None
|
|
182
|
+
if not cmd:
|
|
183
|
+
sys.stdout.write("absent\n")
|
|
184
|
+
return
|
|
185
|
+
if cmd != OUR_COMMAND:
|
|
186
|
+
# present but points elsewhere (e.g. still the agent-config path) — not ours.
|
|
187
|
+
sys.stdout.write("not-ours\n")
|
|
188
|
+
return
|
|
189
|
+
sys.stdout.write(f"configured {cmd}\n")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def main(argv: list) -> None:
|
|
193
|
+
if len(argv) < 2:
|
|
194
|
+
_die(5, "usage: agy-statusline-config.py <install|uninstall|doctor-static> ...")
|
|
195
|
+
sub = argv[1]
|
|
196
|
+
if sub == "install":
|
|
197
|
+
if len(argv) != 5:
|
|
198
|
+
_die(5, "usage: agy-statusline-config.py install <settings_path> <command> <state_path>")
|
|
199
|
+
cmd_install(argv[2], argv[3], argv[4])
|
|
200
|
+
elif sub == "uninstall":
|
|
201
|
+
if len(argv) != 3:
|
|
202
|
+
_die(5, "usage: agy-statusline-config.py uninstall <state_path>")
|
|
203
|
+
cmd_uninstall(argv[2])
|
|
204
|
+
elif sub == "doctor-static":
|
|
205
|
+
if len(argv) != 3:
|
|
206
|
+
_die(5, "usage: agy-statusline-config.py doctor-static <settings_path>")
|
|
207
|
+
cmd_doctor_static(argv[2])
|
|
208
|
+
else:
|
|
209
|
+
_die(5, f"agy-statusline-config.py: unknown subcommand {sub!r}")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
if __name__ == "__main__":
|
|
213
|
+
main(sys.argv)
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# agy-statusline — Antigravity (agy) native statusline with garden identity.
|
|
3
|
+
#
|
|
4
|
+
# The agy mirror of meta-bridge-statusline.sh (봉인 7: separate adapter, shared
|
|
5
|
+
# PATTERN not code). It preserves GLG's ambient UX (device, cwd[branch], model,
|
|
6
|
+
# context usage) and adds the garden id, looked up by the agy native
|
|
7
|
+
# `conversation_id` (== `session_id`) against the meta-session record BODIES —
|
|
8
|
+
# the SAME authority meta-bridge uses, no cwd back-match, no gid invention.
|
|
9
|
+
#
|
|
10
|
+
# Why a SEPARATE renderer (not the cc one): agy's stdin schema differs
|
|
11
|
+
# (conversation_id / total_input_tokens / agent_state) and "cc" labels are
|
|
12
|
+
# Claude-specific. The garden-lookup logic is duplicated deliberately for now;
|
|
13
|
+
# extracting a shared helper is a follow-up (harness-specific honesty > DRY).
|
|
14
|
+
#
|
|
15
|
+
# The command written into agy settings.statusLine is ALWAYS the bare stable bin
|
|
16
|
+
# `entwurf-agy-statusline` (dev AND installed) — never a repo/checkout path. dev
|
|
17
|
+
# resolves it via `expose-dev-bin`; installed via the npm bin-link (#46 원문
|
|
18
|
+
# tripwire; oracle dangling 교훈). This DIFFERS from meta-bridge's dev repo-path
|
|
19
|
+
# branch, which is claude-side legacy by-design — claude 동형화는 후속 판단.
|
|
20
|
+
#
|
|
21
|
+
# Runtime dependency: python3. High-frequency surface → the lookup is BOUNDED
|
|
22
|
+
# (conversation_id→gid file cache + a wall-clock scan timeout + duplicate
|
|
23
|
+
# early-exit) so it never full-scans 300+ records on every render.
|
|
24
|
+
set -euo pipefail
|
|
25
|
+
|
|
26
|
+
input=$(cat)
|
|
27
|
+
# NEVER error out — agy renders this every turn; a broken exit would put a broken
|
|
28
|
+
# line in front of GLG on every prompt. python3 gone → quiet minimal line; parser
|
|
29
|
+
# dies → `|| true` keeps the shell alive. doctor/smoke are the fail-LOUD surfaces.
|
|
30
|
+
if ! command -v python3 >/dev/null 2>&1; then
|
|
31
|
+
device="$(cat "$HOME/.current-device" 2>/dev/null || echo UNKNOWN)"
|
|
32
|
+
printf '%s ?\n🪛 ? agy' "$device"
|
|
33
|
+
exit 0
|
|
34
|
+
fi
|
|
35
|
+
STATUSLINE_INPUT="$input" python3 - <<'PY' || true
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import json
|
|
39
|
+
import os
|
|
40
|
+
import subprocess
|
|
41
|
+
import sys
|
|
42
|
+
import time
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
from typing import Any
|
|
45
|
+
|
|
46
|
+
RESET = "\033[0m"
|
|
47
|
+
DIM = "\033[2m"
|
|
48
|
+
CYAN_BOLD = "\033[1;36m"
|
|
49
|
+
GREEN = "\033[32m"
|
|
50
|
+
YELLOW = "\033[33m"
|
|
51
|
+
RED_BOLD = "\033[31;1m"
|
|
52
|
+
|
|
53
|
+
# Bounded-lookup knobs. The cache keys on conversation_id (stable per session) so
|
|
54
|
+
# the common case (same conversation, every turn) never re-scans. The wall clock
|
|
55
|
+
# caps a cache-miss scan; overrun degrades to an honest "?" rather than a stall.
|
|
56
|
+
CACHE_TTL_S = 30.0
|
|
57
|
+
SCAN_BUDGET_S = 0.4
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def load_input() -> dict[str, Any]:
|
|
61
|
+
raw = os.environ.get("STATUSLINE_INPUT", "")
|
|
62
|
+
try:
|
|
63
|
+
data = json.loads(raw) if raw.strip() else {}
|
|
64
|
+
except Exception:
|
|
65
|
+
return {}
|
|
66
|
+
return data if isinstance(data, dict) else {}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def dig(obj: Any, *keys: str) -> Any:
|
|
70
|
+
cur = obj
|
|
71
|
+
for key in keys:
|
|
72
|
+
if not isinstance(cur, dict):
|
|
73
|
+
return None
|
|
74
|
+
cur = cur.get(key)
|
|
75
|
+
return cur
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def device_label() -> str:
|
|
79
|
+
try:
|
|
80
|
+
text = Path.home().joinpath(".current-device").read_text(encoding="utf-8").strip()
|
|
81
|
+
return text or "UNKNOWN"
|
|
82
|
+
except Exception:
|
|
83
|
+
return "UNKNOWN"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def shorten_home(path_text: str) -> str:
|
|
87
|
+
home = str(Path.home())
|
|
88
|
+
if path_text == home:
|
|
89
|
+
return "~"
|
|
90
|
+
if path_text.startswith(home + os.sep):
|
|
91
|
+
return "~" + path_text[len(home) :]
|
|
92
|
+
return path_text
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def split_cwd(cwd: str) -> tuple[str, str]:
|
|
96
|
+
if "/" in cwd:
|
|
97
|
+
return cwd.rsplit("/", 1)[0] + "/", cwd.rsplit("/", 1)[1]
|
|
98
|
+
return "", cwd
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def model_short(model_id: str) -> str:
|
|
102
|
+
# agy ships Gemini; keep the single-letter cadence of the cc renderer.
|
|
103
|
+
low = model_id.lower()
|
|
104
|
+
if "gemini" in low:
|
|
105
|
+
return "g"
|
|
106
|
+
return "?"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def git_branch(cwd: str) -> str:
|
|
110
|
+
real_cwd = os.path.abspath(os.path.expanduser(cwd)) if cwd else os.getcwd()
|
|
111
|
+
try:
|
|
112
|
+
subprocess.run(["git", "rev-parse", "--git-dir"], cwd=real_cwd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
|
|
113
|
+
result = subprocess.run(["git", "branch", "--show-current"], cwd=real_cwd, text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, check=False)
|
|
114
|
+
branch = result.stdout.strip()
|
|
115
|
+
return f" [{branch}]" if branch else ""
|
|
116
|
+
except Exception:
|
|
117
|
+
return ""
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def human_tokens(n: int) -> str:
|
|
121
|
+
return f"{n / 1000:.1f}K" if n >= 1000 else str(n)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def limit_label(n: int) -> str:
|
|
125
|
+
return f"{n / 1000000:.0f}M" if n >= 1000000 else f"{n / 1000:.0f}K"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def context_info(data: dict[str, Any]) -> str:
|
|
129
|
+
# agy's context_window carries flat totals (total_input_tokens/…) and a
|
|
130
|
+
# possibly-null current_usage — distinct from cc's current_usage.* sub-object.
|
|
131
|
+
ctx = data.get("context_window")
|
|
132
|
+
if not isinstance(ctx, dict):
|
|
133
|
+
return ""
|
|
134
|
+
try:
|
|
135
|
+
limit = int(ctx.get("context_window_size") or 0)
|
|
136
|
+
pct = int(float(ctx.get("used_percentage") or 0))
|
|
137
|
+
current = int(ctx.get("total_input_tokens") or 0) + int(ctx.get("total_output_tokens") or 0)
|
|
138
|
+
except Exception:
|
|
139
|
+
return ""
|
|
140
|
+
if limit <= 0:
|
|
141
|
+
return ""
|
|
142
|
+
color = RED_BOLD if pct >= 85 else YELLOW if pct >= 70 else GREEN
|
|
143
|
+
return f" | {color}{human_tokens(current)}/{limit_label(limit)} {pct}%{RESET}{DIM}"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def meta_sessions_dir() -> Path:
|
|
147
|
+
override = os.environ.get("ENTWURF_META_SESSIONS_DIR")
|
|
148
|
+
if override:
|
|
149
|
+
return Path(override).expanduser().resolve()
|
|
150
|
+
agent = os.environ.get("PI_CODING_AGENT_DIR")
|
|
151
|
+
if agent:
|
|
152
|
+
return Path(agent).expanduser().resolve() / "meta-sessions"
|
|
153
|
+
return Path.home() / ".pi" / "agent" / "meta-sessions"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def cache_path() -> Path:
|
|
157
|
+
root = os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache")
|
|
158
|
+
return Path(root).expanduser() / "entwurf" / "agy-statusline-gid.json"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def cache_get(native_id: str) -> str | None:
|
|
162
|
+
try:
|
|
163
|
+
rec = json.loads(cache_path().read_text(encoding="utf-8"))
|
|
164
|
+
except Exception:
|
|
165
|
+
return None
|
|
166
|
+
if not isinstance(rec, dict):
|
|
167
|
+
return None
|
|
168
|
+
if rec.get("nativeId") != native_id:
|
|
169
|
+
return None
|
|
170
|
+
ts = rec.get("ts")
|
|
171
|
+
if not isinstance(ts, (int, float)) or (time.time() - ts) > CACHE_TTL_S:
|
|
172
|
+
return None
|
|
173
|
+
gid = rec.get("gid")
|
|
174
|
+
return gid if isinstance(gid, str) else None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def cache_put(native_id: str, gid: str) -> None:
|
|
178
|
+
try:
|
|
179
|
+
p = cache_path()
|
|
180
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
181
|
+
tmp = p.with_suffix(f".tmp-{os.getpid()}")
|
|
182
|
+
tmp.write_text(json.dumps({"nativeId": native_id, "gid": gid, "ts": time.time()}), encoding="utf-8")
|
|
183
|
+
tmp.replace(p)
|
|
184
|
+
except Exception:
|
|
185
|
+
pass # cache is best-effort; a write failure must never break the line
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def scan_gid(native_id: str) -> str:
|
|
189
|
+
# BOUNDED scan: newest records first (an agy conversation is freshly
|
|
190
|
+
# registered), duplicate early-exit, and a wall-clock budget. Overrun → "?".
|
|
191
|
+
root = meta_sessions_dir()
|
|
192
|
+
if not root.exists():
|
|
193
|
+
return "?"
|
|
194
|
+
try:
|
|
195
|
+
entries = sorted(root.glob("*.meta.json"), key=lambda f: f.stat().st_mtime, reverse=True)
|
|
196
|
+
except Exception:
|
|
197
|
+
return "?"
|
|
198
|
+
deadline = time.monotonic() + SCAN_BUDGET_S
|
|
199
|
+
matches: list[str] = []
|
|
200
|
+
for file in entries:
|
|
201
|
+
if time.monotonic() > deadline:
|
|
202
|
+
return "?" # honest degrade, never a stall or an invented gid
|
|
203
|
+
try:
|
|
204
|
+
record = json.loads(file.read_text(encoding="utf-8"))
|
|
205
|
+
except Exception:
|
|
206
|
+
continue
|
|
207
|
+
if isinstance(record, dict) and record.get("nativeSessionId") == native_id:
|
|
208
|
+
garden = record.get("gardenId")
|
|
209
|
+
matches.append(garden if isinstance(garden, str) and garden else "!")
|
|
210
|
+
if len(matches) > 1:
|
|
211
|
+
return "!" # duplicate fail-fast (also caps the scan)
|
|
212
|
+
if len(matches) == 1:
|
|
213
|
+
return matches[0]
|
|
214
|
+
return "?"
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def garden_lookup(native_id: str) -> str:
|
|
218
|
+
if not native_id:
|
|
219
|
+
return "ready"
|
|
220
|
+
cached = cache_get(native_id)
|
|
221
|
+
if cached is not None:
|
|
222
|
+
return cached
|
|
223
|
+
gid = scan_gid(native_id)
|
|
224
|
+
# Only memoize a settled, unambiguous answer — never cache a "?"/"!"/"ready"
|
|
225
|
+
# so a still-registering conversation is re-checked next turn.
|
|
226
|
+
if gid not in ("?", "!", "ready"):
|
|
227
|
+
cache_put(native_id, gid)
|
|
228
|
+
return gid
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def main() -> None:
|
|
232
|
+
data = load_input()
|
|
233
|
+
raw_cwd = dig(data, "workspace", "current_dir") or data.get("cwd") or "?"
|
|
234
|
+
cwd = shorten_home(str(raw_cwd))
|
|
235
|
+
cwd_dir, cwd_tail = split_cwd(cwd)
|
|
236
|
+
|
|
237
|
+
model_id = dig(data, "model", "display_name") or dig(data, "model", "id") or "?"
|
|
238
|
+
model = model_short(str(model_id))
|
|
239
|
+
vterm = "v" if os.environ.get("INSIDE_EMACS") == "vterm" else ""
|
|
240
|
+
|
|
241
|
+
# agy gives both conversation_id and session_id (same value); prefer the
|
|
242
|
+
# canonical conversation_id, fall back to session_id.
|
|
243
|
+
native_id = data.get("conversation_id") or data.get("session_id")
|
|
244
|
+
garden = garden_lookup(native_id if isinstance(native_id, str) else "")
|
|
245
|
+
line1 = (
|
|
246
|
+
f"{DIM}{device_label()} {cwd_dir}{RESET}"
|
|
247
|
+
f"{CYAN_BOLD}{cwd_tail}{RESET}"
|
|
248
|
+
f"{DIM}{git_branch(str(raw_cwd))}{RESET}"
|
|
249
|
+
)
|
|
250
|
+
line2 = f"{DIM}🪛 {garden} agy | {model}{vterm}{context_info(data)}{RESET}"
|
|
251
|
+
sys.stdout.write(f"{line1}\n{line2}")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
if __name__ == "__main__":
|
|
255
|
+
main()
|
|
256
|
+
PY
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# Raw entwurf-bridge dist emit (the inner half of the `build-bridge` npm script).
|
|
4
|
+
# `rm -rf` the dist dir then tsc-emit into it. Serialization against concurrent
|
|
5
|
+
# packs/builds is NOT done here — it is the caller's job via scripts/with-dist-lock.sh
|
|
6
|
+
# (package.json wraps this: `with-dist-lock.sh build-bridge.sh`). Keeping the emit
|
|
7
|
+
# lock-free here is deliberate: the vulnerable window is the WHOLE `npm pack`
|
|
8
|
+
# (prepack build + npm's post-build dist read), not just this emit, so the lock has
|
|
9
|
+
# to wrap the pack, not this script — see with-dist-lock.sh for the full rationale.
|
|
10
|
+
set -euo pipefail
|
|
11
|
+
|
|
12
|
+
REPO_DIR=$(cd -P -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
|
|
13
|
+
|
|
14
|
+
rm -rf "$REPO_DIR/mcp/entwurf-bridge/dist"
|
|
15
|
+
|
|
16
|
+
# Prefer the repo-local tsc (present under both pnpm and npm layouts); fall back to
|
|
17
|
+
# a PATH tsc for a bare invocation outside the npm lifecycle.
|
|
18
|
+
TSC="$REPO_DIR/node_modules/.bin/tsc"
|
|
19
|
+
[ -x "$TSC" ] || TSC=tsc
|
|
20
|
+
"$TSC" -p "$REPO_DIR/mcp/entwurf-bridge/tsconfig.build.json"
|