@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.
Files changed (93) hide show
  1. package/AGENTS.md +31 -17
  2. package/BASELINE.md +42 -8
  3. package/CHANGELOG.md +44 -0
  4. package/DELIVERY.md +71 -14
  5. package/README.md +77 -45
  6. package/VERIFY.md +33 -38
  7. package/docs/setup-clean-host.md +80 -22
  8. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +90 -66
  9. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/acp-client.js +54 -0
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +153 -0
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/config.js +436 -0
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/context.js +157 -0
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/engraving.js +105 -0
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +90 -0
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +194 -0
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +153 -0
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +42 -9
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +49 -13
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +104 -11
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +30 -1
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-native-push.js +57 -0
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +10 -0
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +9 -0
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +21 -0
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +5 -0
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +17 -0
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +125 -0
  28. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/adapter.js +158 -0
  29. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/register.js +61 -0
  30. package/mcp/entwurf-bridge/dist/scripts/agy-imprint.js +166 -0
  31. package/mcp/entwurf-bridge/dist/scripts/doctor-pi-provider.js +130 -0
  32. package/mcp/entwurf-bridge/dist/scripts/meta-bridge-prune.js +178 -0
  33. package/mcp/entwurf-bridge/dist/scripts/new-session-id.js +24 -0
  34. package/mcp/entwurf-bridge/src/index.ts +101 -67
  35. package/mcp/entwurf-bridge/test.sh +1 -1
  36. package/mcp/entwurf-bridge/tsconfig.build.json +23 -3
  37. package/package.json +10 -6
  38. package/pi-extensions/lib/entwurf-deliverability.ts +62 -9
  39. package/pi-extensions/lib/entwurf-self-address.ts +58 -15
  40. package/pi-extensions/lib/entwurf-v2-contract.ts +120 -12
  41. package/pi-extensions/lib/entwurf-v2-decider.ts +60 -0
  42. package/pi-extensions/lib/entwurf-v2-native-push.ts +86 -0
  43. package/pi-extensions/lib/entwurf-v2-production.ts +20 -0
  44. package/pi-extensions/lib/entwurf-v2-release.ts +9 -0
  45. package/pi-extensions/lib/entwurf-v2-runner.ts +29 -1
  46. package/pi-extensions/lib/entwurf-v2-send.ts +7 -0
  47. package/pi-extensions/lib/entwurf-v2-surface.ts +17 -0
  48. package/pi-extensions/lib/meta-sender-identity.ts +154 -0
  49. package/pi-extensions/lib/native-push/adapter.ts +255 -0
  50. package/pi-extensions/lib/native-push/register.ts +99 -0
  51. package/run.sh +974 -248
  52. package/scripts/agy-bridge-config.py +446 -0
  53. package/scripts/agy-bridge.sh +359 -0
  54. package/scripts/agy-hooks-bridge.sh +193 -0
  55. package/scripts/agy-hooks-config.py +257 -0
  56. package/scripts/agy-imprint.sh +28 -0
  57. package/scripts/agy-imprint.ts +193 -0
  58. package/scripts/agy-statusline-bridge.sh +176 -0
  59. package/scripts/agy-statusline-config.py +213 -0
  60. package/scripts/agy-statusline.sh +256 -0
  61. package/scripts/build-bridge.sh +20 -0
  62. package/scripts/check-agy-sender-identity.ts +364 -0
  63. package/scripts/check-entwurf-bridge-boot.ts +8 -2
  64. package/scripts/check-entwurf-deliverability.ts +34 -0
  65. package/scripts/check-entwurf-self-address.ts +78 -11
  66. package/scripts/check-entwurf-v2-contract.ts +136 -1
  67. package/scripts/check-entwurf-v2-decider.ts +95 -1
  68. package/scripts/check-entwurf-v2-matrix.ts +14 -3
  69. package/scripts/check-entwurf-v2-native-push.ts +193 -0
  70. package/scripts/check-entwurf-v2-production.ts +68 -1
  71. package/scripts/check-entwurf-v2-runner.ts +58 -0
  72. package/scripts/check-entwurf-v2-surface.ts +35 -0
  73. package/scripts/check-install-surface.ts +357 -0
  74. package/scripts/check-native-push-adapter.ts +319 -0
  75. package/scripts/check-native-push-register.ts +130 -0
  76. package/scripts/dev-bin.sh +195 -0
  77. package/scripts/doctor-pi-provider.ts +140 -0
  78. package/scripts/meta-bridge-doctor.sh +47 -9
  79. package/scripts/meta-bridge-install.sh +20 -17
  80. package/scripts/meta-bridge-state.py +40 -2
  81. package/scripts/meta-bridge-uninstall.sh +23 -12
  82. package/scripts/register-pi-package.py +183 -0
  83. package/scripts/register-pi-provider.py +287 -0
  84. package/scripts/smoke-agy-hooks-state.sh +172 -0
  85. package/scripts/smoke-agy-install-state.sh +660 -0
  86. package/scripts/smoke-agy-native-push-live.ts +243 -0
  87. package/scripts/smoke-agy-statusline-state.sh +300 -0
  88. package/scripts/smoke-meta-async-drift.sh +9 -2
  89. package/scripts/smoke-meta-install-state.sh +178 -8
  90. package/scripts/smoke-pi-provider-state.sh +182 -0
  91. package/scripts/smoke-user-scope-citizen.sh +191 -0
  92. package/scripts/with-dist-lock.sh +81 -0
  93. 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"