@junghanacs/entwurf 0.12.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.
Files changed (183) hide show
  1. package/AGENTS.md +240 -0
  2. package/BASELINE.md +227 -0
  3. package/CHANGELOG.md +1210 -0
  4. package/CONTRIBUTING.md +63 -0
  5. package/DELIVERY.md +209 -0
  6. package/LICENSE +21 -0
  7. package/README.md +504 -0
  8. package/VERIFY.md +260 -0
  9. package/demo/README.md +188 -0
  10. package/demo/demo-baseline.sh +156 -0
  11. package/demo/demo.sh +183 -0
  12. package/docs/assets/entwurf-demo.gif +0 -0
  13. package/docs/assets/entwurf-doomemacs.gif +0 -0
  14. package/docs/assets/entwurf-entwurf.gif +0 -0
  15. package/docs/assets/entwurf-hero.jpg +0 -0
  16. package/docs/setup-clean-host.md +305 -0
  17. package/mcp/entwurf-bridge/src/index.ts +513 -0
  18. package/mcp/entwurf-bridge/start.sh +25 -0
  19. package/mcp/entwurf-bridge/test.sh +54 -0
  20. package/mcp/tsconfig.json +29 -0
  21. package/package.json +130 -0
  22. package/pi/entwurf-capabilities.json +9 -0
  23. package/pi/entwurf-targets.json +20 -0
  24. package/pi/meta-bridge/.claude-plugin/marketplace.json +12 -0
  25. package/pi/meta-bridge/entwurf-meta-receive/.claude-plugin/plugin.json +5 -0
  26. package/pi/meta-bridge/entwurf-meta-receive/hooks/hooks.json +50 -0
  27. package/pi/meta-bridge/entwurf-meta-receive/scripts/doorbell.sh +54 -0
  28. package/pi/settings.reference.json +43 -0
  29. package/pi/skill-plugin-example/.claude-plugin/plugin.json +4 -0
  30. package/pi/skill-plugin-example/skills/hello/SKILL.md +18 -0
  31. package/pi-extensions/acp-provider.ts +63 -0
  32. package/pi-extensions/entwurf-control.ts +1692 -0
  33. package/pi-extensions/lib/acp/acp-client.ts +90 -0
  34. package/pi-extensions/lib/acp/augment.ts +238 -0
  35. package/pi-extensions/lib/acp/backend-adapter.ts +319 -0
  36. package/pi-extensions/lib/acp/backend.ts +873 -0
  37. package/pi-extensions/lib/acp/config.ts +552 -0
  38. package/pi-extensions/lib/acp/context.ts +177 -0
  39. package/pi-extensions/lib/acp/engraving.ts +123 -0
  40. package/pi-extensions/lib/acp/event-mapper.ts +339 -0
  41. package/pi-extensions/lib/acp/models.ts +102 -0
  42. package/pi-extensions/lib/acp/overlay.ts +220 -0
  43. package/pi-extensions/lib/acp/prompts/engraving.md +1 -0
  44. package/pi-extensions/lib/acp/session-store.ts +418 -0
  45. package/pi-extensions/lib/acp/tool-surface.ts +184 -0
  46. package/pi-extensions/lib/entwurf-control-rpc.ts +208 -0
  47. package/pi-extensions/lib/entwurf-core.ts +2033 -0
  48. package/pi-extensions/lib/entwurf-deliverability.ts +123 -0
  49. package/pi-extensions/lib/entwurf-fact-provider.ts +158 -0
  50. package/pi-extensions/lib/entwurf-facts.ts +251 -0
  51. package/pi-extensions/lib/entwurf-mailbox-guard.ts +100 -0
  52. package/pi-extensions/lib/entwurf-peers-render.ts +154 -0
  53. package/pi-extensions/lib/entwurf-preflight.ts +247 -0
  54. package/pi-extensions/lib/entwurf-resume-args.ts +88 -0
  55. package/pi-extensions/lib/entwurf-self-address.ts +117 -0
  56. package/pi-extensions/lib/entwurf-v2-contract.ts +427 -0
  57. package/pi-extensions/lib/entwurf-v2-decider.ts +441 -0
  58. package/pi-extensions/lib/entwurf-v2-lock.ts +412 -0
  59. package/pi-extensions/lib/entwurf-v2-mailbox.ts +87 -0
  60. package/pi-extensions/lib/entwurf-v2-production.ts +326 -0
  61. package/pi-extensions/lib/entwurf-v2-release.ts +149 -0
  62. package/pi-extensions/lib/entwurf-v2-resume-marker.ts +34 -0
  63. package/pi-extensions/lib/entwurf-v2-runner.ts +196 -0
  64. package/pi-extensions/lib/entwurf-v2-send-fallback.ts +166 -0
  65. package/pi-extensions/lib/entwurf-v2-send.ts +271 -0
  66. package/pi-extensions/lib/entwurf-v2-spawn-production.ts +337 -0
  67. package/pi-extensions/lib/entwurf-v2-spawn.ts +323 -0
  68. package/pi-extensions/lib/entwurf-v2-surface.ts +205 -0
  69. package/pi-extensions/lib/meta-mailbox-body.ts +80 -0
  70. package/pi-extensions/lib/meta-session.ts +1951 -0
  71. package/pi-extensions/lib/project-trust-handler.ts +154 -0
  72. package/pi-extensions/lib/session-id.js +57 -0
  73. package/pi-extensions/lib/socket-discovery.ts +346 -0
  74. package/pi-extensions/lib/socket-probe.ts +92 -0
  75. package/pi-extensions/meta-bridge-hook.ts +250 -0
  76. package/pi-extensions/model-lock.ts +235 -0
  77. package/prompts/engraving.md +27 -0
  78. package/protocol.js +31 -0
  79. package/run.sh +2832 -0
  80. package/scripts/check-acp-backend-preflight.ts +130 -0
  81. package/scripts/check-acp-carrier-augment.ts +297 -0
  82. package/scripts/check-acp-config.ts +322 -0
  83. package/scripts/check-acp-event-mapper.ts +253 -0
  84. package/scripts/check-acp-overlay.ts +154 -0
  85. package/scripts/check-acp-prompt-builder.ts +191 -0
  86. package/scripts/check-acp-provider-surface.ts +159 -0
  87. package/scripts/check-acp-sdk-surface.ts +211 -0
  88. package/scripts/check-acp-session-reuse.ts +756 -0
  89. package/scripts/check-acp-session-store.ts +387 -0
  90. package/scripts/check-acp-tool-surface.ts +159 -0
  91. package/scripts/check-entwurf-bridge-boot.ts +199 -0
  92. package/scripts/check-entwurf-capabilities.ts +123 -0
  93. package/scripts/check-entwurf-control-rpc.ts +187 -0
  94. package/scripts/check-entwurf-deliverability.ts +158 -0
  95. package/scripts/check-entwurf-fact-provider.ts +316 -0
  96. package/scripts/check-entwurf-facts.ts +358 -0
  97. package/scripts/check-entwurf-mailbox-guard.ts +264 -0
  98. package/scripts/check-entwurf-peers-surface.ts +235 -0
  99. package/scripts/check-entwurf-resume-args.ts +149 -0
  100. package/scripts/check-entwurf-self-address.ts +209 -0
  101. package/scripts/check-entwurf-session-identity.ts +703 -0
  102. package/scripts/check-entwurf-v2-contract.ts +536 -0
  103. package/scripts/check-entwurf-v2-decider.ts +795 -0
  104. package/scripts/check-entwurf-v2-lock.ts +368 -0
  105. package/scripts/check-entwurf-v2-mailbox.ts +228 -0
  106. package/scripts/check-entwurf-v2-matrix.ts +437 -0
  107. package/scripts/check-entwurf-v2-production.ts +406 -0
  108. package/scripts/check-entwurf-v2-release.ts +245 -0
  109. package/scripts/check-entwurf-v2-runner.ts +452 -0
  110. package/scripts/check-entwurf-v2-send-fallback.ts +371 -0
  111. package/scripts/check-entwurf-v2-send.ts +454 -0
  112. package/scripts/check-entwurf-v2-spawn-production.ts +452 -0
  113. package/scripts/check-entwurf-v2-spawn.ts +398 -0
  114. package/scripts/check-entwurf-v2-surface.ts +352 -0
  115. package/scripts/check-keyset-overlap.py +120 -0
  116. package/scripts/check-mailbox-receipt-state.ts +170 -0
  117. package/scripts/check-meta-capability-source.ts +112 -0
  118. package/scripts/check-meta-dual-consumers.ts +154 -0
  119. package/scripts/check-meta-dual-read.ts +158 -0
  120. package/scripts/check-meta-listing.ts +138 -0
  121. package/scripts/check-meta-mailbox-state-write.ts +135 -0
  122. package/scripts/check-meta-migration.ts +212 -0
  123. package/scripts/check-meta-receiver-marker.ts +185 -0
  124. package/scripts/check-meta-record-v2.ts +191 -0
  125. package/scripts/check-meta-session.ts +673 -0
  126. package/scripts/check-model-lock.ts +408 -0
  127. package/scripts/check-package-source-routing.ts +253 -0
  128. package/scripts/check-pi-preflight.ts +304 -0
  129. package/scripts/check-project-trust-handler.ts +265 -0
  130. package/scripts/check-shell-quote.ts +121 -0
  131. package/scripts/check-socket-discovery.ts +428 -0
  132. package/scripts/check-socket-probe.ts +106 -0
  133. package/scripts/fixtures/probe-mcp-server.ts +33 -0
  134. package/scripts/gnew-rpc-drive.ts +211 -0
  135. package/scripts/lib/acp-child-cleanup.ts +116 -0
  136. package/scripts/meta-bridge-doctor.sh +315 -0
  137. package/scripts/meta-bridge-hook-log.sh +26 -0
  138. package/scripts/meta-bridge-install.sh +135 -0
  139. package/scripts/meta-bridge-prune.ts +199 -0
  140. package/scripts/meta-bridge-state.py +549 -0
  141. package/scripts/meta-bridge-statusline.sh +192 -0
  142. package/scripts/meta-bridge-store-doctor.ts +64 -0
  143. package/scripts/meta-bridge-uninstall.sh +39 -0
  144. package/scripts/new-session-id.ts +25 -0
  145. package/scripts/postinstall-chmod.cjs +58 -0
  146. package/scripts/raw-async-delivery/README.md +258 -0
  147. package/scripts/raw-async-delivery/cc-enqueue-addressed.sh +35 -0
  148. package/scripts/raw-async-delivery/cc-mailbox-rewake.sh +38 -0
  149. package/scripts/raw-async-delivery/cc-watch-filechanged.sh +19 -0
  150. package/scripts/raw-async-delivery/cc-watch-sessionstart.sh +15 -0
  151. package/scripts/raw-async-delivery/codex-local-appserver.sh +39 -0
  152. package/scripts/raw-async-delivery/plugin-entwurf-receive/.claude-plugin/plugin.json +5 -0
  153. package/scripts/raw-async-delivery/plugin-entwurf-receive/hooks/hooks.json +28 -0
  154. package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-filechanged.sh +28 -0
  155. package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-sessionstart.sh +27 -0
  156. package/scripts/raw-async-delivery/raw-agy-send.sh +29 -0
  157. package/scripts/raw-async-delivery/raw-claude-enqueue.sh +30 -0
  158. package/scripts/raw-async-delivery/raw-codex-ws-turn-start.py +164 -0
  159. package/scripts/raw-async-delivery/repro-addressed-routing.sh +96 -0
  160. package/scripts/raw-async-delivery/repro-plugin-idle-wake.sh +104 -0
  161. package/scripts/resolve-acp-bridge.ts +25 -0
  162. package/scripts/smoke-acp-bundled-mcp-live.ts +292 -0
  163. package/scripts/smoke-acp-carrier-augment-live.ts +192 -0
  164. package/scripts/smoke-acp-mcp-live.ts +129 -0
  165. package/scripts/smoke-acp-memory-containment-live.ts +389 -0
  166. package/scripts/smoke-acp-overlay-live.ts +314 -0
  167. package/scripts/smoke-acp-provider-live.ts +162 -0
  168. package/scripts/smoke-acp-raw-turn-live.ts +261 -0
  169. package/scripts/smoke-acp-session-reuse-live.ts +172 -0
  170. package/scripts/smoke-acp-skill-live.ts +144 -0
  171. package/scripts/smoke-acp-socket-citizen-live.ts +168 -0
  172. package/scripts/smoke-claude-native-resume-live.sh +198 -0
  173. package/scripts/smoke-entwurf-v2-matrix-live.ts +398 -0
  174. package/scripts/smoke-entwurf-v2-spawn-live.ts +175 -0
  175. package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +428 -0
  176. package/scripts/smoke-meta-async-drift.sh +171 -0
  177. package/scripts/smoke-meta-honesty.sh +147 -0
  178. package/scripts/smoke-meta-install-state.sh +403 -0
  179. package/scripts/smoke-meta-keyset-guard.sh +111 -0
  180. package/scripts/smoke-meta-prune.sh +174 -0
  181. package/scripts/smoke-resident-garden-guard.sh +433 -0
  182. package/scripts/smoke-session-id-name.ts +187 -0
  183. package/scripts/tsconfig.json +34 -0
@@ -0,0 +1,192 @@
1
+ #!/usr/bin/env bash
2
+ # meta-bridge-statusline — Claude Code native statusline with garden identity.
3
+ #
4
+ # Repo-owned Phase-3 statusline. It preserves GLG's current UX data
5
+ # (device, cwd[branch], model, context usage) while rendering it as a two-row
6
+ # Claude Code status area: row 1 = work context, row 2 = garden identity. The
7
+ # garden id is looked up by scanning meta-record BODIES via the native Claude
8
+ # `session_id`. No cache, no filename authority, no DB.
9
+ #
10
+ # Runtime dependency: python3 (already gated by install-meta-bridge/doctor).
11
+ set -euo pipefail
12
+
13
+ input=$(cat)
14
+ # The statusline must NEVER error out — Claude renders it every turn, so a broken
15
+ # exit would put a broken line in front of GLG on every prompt. If python3 is gone
16
+ # (e.g. a nix GC between installs) degrade to a quiet minimal line; if the parser
17
+ # itself dies, `|| true` keeps the shell alive with whatever (possibly empty) it
18
+ # wrote. doctor/smoke are the fail-LOUD surfaces; this surface stays silent.
19
+ if ! command -v python3 >/dev/null 2>&1; then
20
+ device="$(cat "$HOME/.current-device" 2>/dev/null || echo UNKNOWN)"
21
+ printf '%s ?\n🪛 ? cc' "$device"
22
+ exit 0
23
+ fi
24
+ STATUSLINE_INPUT="$input" python3 - <<'PY' || true
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import os
29
+ import subprocess
30
+ import sys
31
+ from pathlib import Path
32
+ from typing import Any
33
+
34
+ RESET = "\033[0m"
35
+ DIM = "\033[2m"
36
+ CYAN_BOLD = "\033[1;36m"
37
+ GREEN = "\033[32m"
38
+ YELLOW = "\033[33m"
39
+ RED_BOLD = "\033[31;1m"
40
+
41
+
42
+ def load_input() -> dict[str, Any]:
43
+ raw = os.environ.get("STATUSLINE_INPUT", "")
44
+ try:
45
+ data = json.loads(raw) if raw.strip() else {}
46
+ except Exception:
47
+ return {}
48
+ return data if isinstance(data, dict) else {}
49
+
50
+
51
+ def dig(obj: Any, *keys: str) -> Any:
52
+ cur = obj
53
+ for key in keys:
54
+ if not isinstance(cur, dict):
55
+ return None
56
+ cur = cur.get(key)
57
+ return cur
58
+
59
+
60
+ def device_label() -> str:
61
+ try:
62
+ text = Path.home().joinpath(".current-device").read_text(encoding="utf-8").strip()
63
+ return text or "UNKNOWN"
64
+ except Exception:
65
+ return "UNKNOWN"
66
+
67
+
68
+ def shorten_home(path_text: str) -> str:
69
+ home = str(Path.home())
70
+ if path_text == home:
71
+ return "~"
72
+ if path_text.startswith(home + os.sep):
73
+ return "~" + path_text[len(home) :]
74
+ return path_text
75
+
76
+
77
+ def split_cwd(cwd: str) -> tuple[str, str]:
78
+ if "/" in cwd:
79
+ return cwd.rsplit("/", 1)[0] + "/", cwd.rsplit("/", 1)[1]
80
+ return "", cwd
81
+
82
+
83
+ def model_short(model_id: str) -> str:
84
+ low = model_id.lower()
85
+ if "opus" in low:
86
+ return "o"
87
+ if "sonnet" in low:
88
+ return "s"
89
+ if "haiku" in low:
90
+ return "h"
91
+ return "?"
92
+
93
+
94
+ def git_branch(cwd: str) -> str:
95
+ real_cwd = os.path.abspath(os.path.expanduser(cwd)) if cwd else os.getcwd()
96
+ try:
97
+ subprocess.run(["git", "rev-parse", "--git-dir"], cwd=real_cwd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
98
+ result = subprocess.run(["git", "branch", "--show-current"], cwd=real_cwd, text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, check=False)
99
+ branch = result.stdout.strip()
100
+ return f" [{branch}]" if branch else ""
101
+ except Exception:
102
+ return ""
103
+
104
+
105
+ def human_tokens(n: int) -> str:
106
+ return f"{n / 1000:.1f}K" if n >= 1000 else str(n)
107
+
108
+
109
+ def limit_label(n: int) -> str:
110
+ return f"{n / 1000000:.0f}M" if n >= 1000000 else f"{n / 1000:.0f}K"
111
+
112
+
113
+ def context_info(data: dict[str, Any]) -> str:
114
+ ctx = data.get("context_window")
115
+ if not isinstance(ctx, dict):
116
+ return ""
117
+ try:
118
+ limit = int(ctx.get("context_window_size") or 0)
119
+ pct = int(float(ctx.get("used_percentage") or 0))
120
+ usage = ctx.get("current_usage") if isinstance(ctx.get("current_usage"), dict) else {}
121
+ current = int(usage.get("input_tokens") or 0) + int(usage.get("output_tokens") or 0) + int(usage.get("cache_creation_input_tokens") or 0) + int(usage.get("cache_read_input_tokens") or 0)
122
+ except Exception:
123
+ return ""
124
+ if limit <= 0:
125
+ return ""
126
+ color = RED_BOLD if pct >= 85 else YELLOW if pct >= 70 else GREEN
127
+ return f" | {color}{human_tokens(current)}/{limit_label(limit)} {pct}%{RESET}{DIM}"
128
+
129
+
130
+ def meta_sessions_dir() -> Path:
131
+ override = os.environ.get("ENTWURF_META_SESSIONS_DIR")
132
+ if override:
133
+ return Path(override).expanduser().resolve()
134
+ agent = os.environ.get("PI_CODING_AGENT_DIR")
135
+ if agent:
136
+ return Path(agent).expanduser().resolve() / "meta-sessions"
137
+ return Path.home() / ".pi" / "agent" / "meta-sessions"
138
+
139
+
140
+ def garden_lookup(native_session_id: str) -> str:
141
+ if not native_session_id:
142
+ return "ready"
143
+ root = meta_sessions_dir()
144
+ if not root.exists():
145
+ return "?"
146
+ matches: list[str] = []
147
+ try:
148
+ entries = sorted(root.glob("*.meta.json"))
149
+ except Exception:
150
+ return "?"
151
+ for file in entries:
152
+ try:
153
+ record = json.loads(file.read_text(encoding="utf-8"))
154
+ except Exception:
155
+ continue
156
+ if isinstance(record, dict) and record.get("nativeSessionId") == native_session_id:
157
+ garden = record.get("gardenId")
158
+ if isinstance(garden, str) and garden:
159
+ matches.append(garden)
160
+ else:
161
+ matches.append("!")
162
+ if len(matches) == 1:
163
+ return matches[0]
164
+ if len(matches) > 1:
165
+ return "!"
166
+ return "?"
167
+
168
+
169
+ def main() -> None:
170
+ data = load_input()
171
+ raw_cwd = dig(data, "workspace", "current_dir") or data.get("cwd") or "?"
172
+ cwd = shorten_home(str(raw_cwd))
173
+ cwd_dir, cwd_tail = split_cwd(cwd)
174
+
175
+ model_id = dig(data, "model", "id") or "?"
176
+ model = model_short(str(model_id))
177
+ vterm = "v" if os.environ.get("INSIDE_EMACS") == "vterm" else ""
178
+
179
+ native_session_id = data.get("session_id")
180
+ garden = garden_lookup(native_session_id if isinstance(native_session_id, str) else "")
181
+ line1 = (
182
+ f"{DIM}{device_label()} {cwd_dir}{RESET}"
183
+ f"{CYAN_BOLD}{cwd_tail}{RESET}"
184
+ f"{DIM}{git_branch(str(raw_cwd))}{RESET}"
185
+ )
186
+ line2 = f"{DIM}🪛 {garden} cc | {model}{vterm}{context_info(data)}{RESET}"
187
+ sys.stdout.write(f"{line1}\n{line2}")
188
+
189
+
190
+ if __name__ == "__main__":
191
+ main()
192
+ PY
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * meta-bridge-store-doctor — fail-loud full scan for meta-session records.
4
+ *
5
+ * The store is the authority for native→garden lookup. Doctor must therefore
6
+ * refuse corrupt records, duplicate nativeSessionId authorities, and body ↔
7
+ * filename drift instead of auto-pruning or silently picking one.
8
+ */
9
+
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ import { parseMetaIdentity } from "../pi-extensions/lib/meta-session.ts";
13
+
14
+ const dir = process.argv[2];
15
+ if (!dir) {
16
+ console.error("usage: node --experimental-strip-types scripts/meta-bridge-store-doctor.ts <meta-sessions-dir>");
17
+ process.exit(2);
18
+ }
19
+
20
+ const failures: string[] = [];
21
+ const nativeToFiles = new Map<string, string[]>();
22
+ let scanned = 0;
23
+
24
+ if (!fs.existsSync(dir)) {
25
+ console.log(`meta-store scan ok: ${dir} does not exist yet (0 records)`);
26
+ process.exit(0);
27
+ }
28
+
29
+ for (const filename of fs.readdirSync(dir).sort()) {
30
+ if (!filename.endsWith(".meta.json")) continue;
31
+ scanned += 1;
32
+ const file = path.join(dir, filename);
33
+ try {
34
+ // dual-read (3D-4 commit1): parseMetaIdentity reads v1 AND v2 and normalizes,
35
+ // so doctor's gardenId/nativeSessionId checks survive the v2 cut.
36
+ const id = parseMetaIdentity(fs.readFileSync(file, "utf8"));
37
+ const expectedFilename = `${id.gardenId}.meta.json`;
38
+ if (filename !== expectedFilename) {
39
+ failures.push(
40
+ `${filename}: body/filename drift — body gardenId=${id.gardenId}, expected filename ${expectedFilename}`,
41
+ );
42
+ }
43
+ const files = nativeToFiles.get(id.nativeSessionId) ?? [];
44
+ files.push(filename);
45
+ nativeToFiles.set(id.nativeSessionId, files);
46
+ } catch (err) {
47
+ failures.push(`${filename}: ${err instanceof Error ? err.message : String(err)}`);
48
+ }
49
+ }
50
+
51
+ for (const [nativeSessionId, files] of nativeToFiles.entries()) {
52
+ if (files.length > 1) {
53
+ failures.push(
54
+ `duplicate nativeSessionId ${JSON.stringify(nativeSessionId)} in ${files.join(", ")} — authority ambiguity; prune manually`,
55
+ );
56
+ }
57
+ }
58
+
59
+ if (failures.length > 0) {
60
+ for (const failure of failures) console.error(`FAIL: ${failure}`);
61
+ process.exit(1);
62
+ }
63
+
64
+ console.log(`meta-store scan ok: ${scanned} record(s) scanned, no corrupt/duplicate/drift records`);
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env bash
2
+ # meta-bridge-uninstall.sh — honest inverse of install-meta-bridge.
3
+ #
4
+ # Uses ${CLAUDE_CONFIG_DIR:-~/.claude}/entwurf.install-state.json to restore
5
+ # exactly the settings/MCP keys Phase 2 install touched. Without that state file,
6
+ # uninstall cannot know whether a scalar false/true/path was user-owned, so it
7
+ # fails instead of guessing.
8
+ set -euo pipefail
9
+
10
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
11
+ REPO="$(cd "$HERE/.." && pwd)"
12
+ MKT_NAME="meta-bridge-local"
13
+ PLUGIN="entwurf-meta-receive"
14
+
15
+ die() { echo "meta-bridge-uninstall: $*" >&2; exit 1; }
16
+
17
+ case "$(uname -s)" in
18
+ Linux | Darwin) ;;
19
+ *) die "unsupported platform '$(uname -s)'. meta-bridge supports Linux + macOS only (Windows is fail-fast, not silently attempted)." ;;
20
+ esac
21
+
22
+ command -v python3 >/dev/null || die "'python3' not on PATH. It is required for stateful uninstall."
23
+
24
+ # Refuse to touch Claude's plugin/MCP registrations until state exists and is
25
+ # valid. Without this preflight, "uninstall without state fails" would still
26
+ # delete live plugin/MCP entries before failing — guessing by side effect.
27
+ python3 "$REPO/scripts/meta-bridge-state.py" preflight-uninstall --repo "$REPO"
28
+
29
+ if command -v claude >/dev/null; then
30
+ claude plugin uninstall "$PLUGIN@$MKT_NAME" >/dev/null 2>&1 || true
31
+ claude plugin marketplace remove "$MKT_NAME" >/dev/null 2>&1 || true
32
+ claude mcp remove entwurf-bridge -s user >/dev/null 2>&1 || true
33
+ echo "[meta-bridge-uninstall] removed Claude plugin/marketplace/MCP registrations when present"
34
+ else
35
+ echo "[meta-bridge-uninstall] WARN: claude CLI not on PATH; restoring JSON state only" >&2
36
+ fi
37
+
38
+ python3 "$REPO/scripts/meta-bridge-state.py" uninstall --repo "$REPO"
39
+ echo "[meta-bridge-uninstall] DONE"
@@ -0,0 +1,25 @@
1
+ /**
2
+ * new-session-id — print one fresh garden-native sessionId and exit.
3
+ *
4
+ * The garden launcher (operator alias / wrapper) calls this so every
5
+ * `--entwurf-control` session is born a garden citizen:
6
+ *
7
+ * pi --session-id "$(./run.sh new-session-id)" --entwurf-control \
8
+ * --emacs-agent-socket server …
9
+ *
10
+ * The id is `generateSessionId()` from entwurf-core — the single SSOT for the
11
+ * locked `YYYYMMDDTHHMMSS-[0-9a-f]{6}` grammar. Do NOT reimplement the format in
12
+ * the shell (it would drift from the validator the resident guard enforces).
13
+ *
14
+ * Stdout is the id and nothing else (no trailing prose), so `$(…)` captures a
15
+ * clean value. Errors go to stderr with a nonzero exit.
16
+ */
17
+
18
+ import { generateSessionId } from "../pi-extensions/lib/entwurf-core.ts";
19
+
20
+ try {
21
+ process.stdout.write(`${generateSessionId()}\n`);
22
+ } catch (err) {
23
+ process.stderr.write(`new-session-id failed: ${err instanceof Error ? err.message : String(err)}\n`);
24
+ process.exit(1);
25
+ }
@@ -0,0 +1,58 @@
1
+ // Restore executable bits on shell scripts after install.
2
+ //
3
+ // Why: `pnpm publish` normalizes file modes in the published tarball
4
+ // to 0644, so the registry artifact loses the executable bit that the
5
+ // repo tracks (`git ls-files --stage` shows 100755). README documents
6
+ // running `run.sh install .` directly, and the entwurf-bridge MCP
7
+ // extension spawns `mcp/entwurf-bridge/start.sh` — both break with
8
+ // EACCES on a fresh `pi install npm:@junghanacs/entwurf`.
9
+ //
10
+ // CJS so it runs regardless of the consumer's package "type" and
11
+ // regardless of whether the installed copy includes our package.json
12
+ // (the script is co-located, no resolution needed).
13
+
14
+ "use strict";
15
+
16
+ const fs = require("node:fs");
17
+ const path = require("node:path");
18
+
19
+ const root = path.resolve(__dirname, "..");
20
+
21
+ const targets = [
22
+ "run.sh",
23
+ "mcp/entwurf-bridge/start.sh",
24
+ "mcp/entwurf-bridge/test.sh",
25
+ "demo/demo.sh",
26
+ "demo/demo-baseline.sh",
27
+ ];
28
+
29
+ const scriptDirs = ["scripts"];
30
+
31
+ function chmodIfPresent(rel) {
32
+ const abs = path.join(root, rel);
33
+ try {
34
+ if (!fs.existsSync(abs)) return;
35
+ fs.chmodSync(abs, 0o755);
36
+ } catch (err) {
37
+ // Never fail install on chmod errors (Windows, read-only mount, etc.).
38
+ console.warn(`[entwurf postinstall] chmod ${rel} skipped: ${err.message}`);
39
+ }
40
+ }
41
+
42
+ for (const rel of targets) {
43
+ chmodIfPresent(rel);
44
+ }
45
+
46
+ for (const dir of scriptDirs) {
47
+ const abs = path.join(root, dir);
48
+ try {
49
+ if (!fs.existsSync(abs)) continue;
50
+ for (const entry of fs.readdirSync(abs)) {
51
+ if (entry.endsWith(".sh")) {
52
+ chmodIfPresent(path.join(dir, entry));
53
+ }
54
+ }
55
+ } catch (err) {
56
+ console.warn(`[entwurf postinstall] scan ${dir}/*.sh skipped: ${err.message}`);
57
+ }
58
+ }
@@ -0,0 +1,258 @@
1
+ # raw-async-delivery — RAW async message delivery into LIVE Claude Code / agy / Codex sessions
2
+
3
+ Goal: deliver an async message INTO an already-running **subscription** session,
4
+ free, with no `claude -p` / backend CLI prompt spawn — and in particular wake an
5
+ **IDLE** session with zero typing (agy `send-message` parity).
6
+
7
+ Codex is split by launch surface:
8
+
9
+ - standalone **Embedded** Codex TUI: no external socket; not addressable, not
10
+ retrofittable.
11
+ - **app-server-backed** Codex TUI (`codex --remote unix://` or plain `codex`
12
+ auto-attached to a default-path app-server): raw `turn/start` over
13
+ WebSocket-over-UDS wakes the live thread — **demonstrated, no managed
14
+ standalone, no cloud**.
15
+
16
+ ## TL;DR — three reception paths, ranked
17
+
18
+ | Path | Self-arms idle watch? | Portable / drop-in | Verdict |
19
+ |------|----------------------|--------------------|---------|
20
+ | **Plugin** (`.claude-plugin` + `hooks/hooks.json`) | ✅ yes | ✅ yes (`--plugin-dir` / install) | **best** — scoped AND works |
21
+ | Global/project `settings.json` hooks | ✅ yes | ⚠️ config edit, not a unit | works; less portable |
22
+ | **Bare skill** (`~/.claude/skills/x/` with `hooks:`) | ❌ **no** | ✅ yes | **structurally unfit for the watch** |
23
+
24
+ ### Why a bare skill cannot arm the idle watch (mechanism, verified)
25
+
26
+ A bare skill's hooks register only when the model **invokes** the skill
27
+ (`getPromptForCommand` → `mL4(setAppState, …, H.hooks, …)`, 2.1.163). That is
28
+ mid-session — **after** `SessionStart` already fired. A `watchPath` can only be
29
+ emitted from `SessionStart` / `CwdChanged` / `FileChanged` hookSpecificOutput, so
30
+ a skill-declared `SessionStart` hook never runs and the watch is never armed.
31
+ The model may *read* `SKILL.md` and role-play "watching", but no hook is armed —
32
+ a dangerous false-positive for a delivery daemon. Measured: `hook.log` stays
33
+ empty; `loadSkillsAsPlugins` only loads dirs that contain `.claude-plugin`.
34
+
35
+ > The earlier conclusion "scoped delivery is impossible → use global settings"
36
+ > was only half right. It is true for a **bare skill**. It is **false** for a
37
+ > **plugin**, which is just as portable/drop-in and whose hooks load at startup.
38
+
39
+ ### Why a plugin works
40
+
41
+ `hooks/hooks.json` inside a plugin is loaded at **startup** (not on invocation),
42
+ so its `SessionStart` hook actually fires and arms the `watchPath`. From there
43
+ `FileChanged(asyncRewake)` wakes the idle session on an external file write.
44
+ Verified live on 2.1.163 / Opus 4.8: idle session, zero typing, ~1–2 s, same
45
+ session + same model.
46
+
47
+ ## Addressed, not broadcast
48
+
49
+ Delivery is **per-session addressed**: each receiving session arms a watchPath at
50
+ `<root>/<session_id>/inbox.signal` (session_id arrives on the SessionStart hook
51
+ stdin). A sender targets ONE `session_id`; only that session's signal changes, so
52
+ **only that session wakes** — siblings stay idle and undisturbed. This is the
53
+ entwurf sessionId-addressing model: "send to the one you want, only it processes."
54
+
55
+ ```
56
+ $CC_MAILBOX_ROOT/<session_id>/inbox.signal # watched (poked by the sender)
57
+ $CC_MAILBOX_ROOT/<session_id>/<ts>.msg # message body (agent self-fetches)
58
+ $CC_MAILBOX_ROOT/<session_id>/hook.log # per-session hook evidence
59
+ ```
60
+
61
+ `CC_MAILBOX_ROOT` defaults to `~/.claude/mailbox`. Sender and receiver must agree
62
+ on the root. A fixed shared signal would broadcast and bother every session — we
63
+ do not ship that. Proven: deliver to A's sessionId → A wakes (FileChanged), B's
64
+ `hook.log` shows zero FileChanged, B pane unchanged (`repro-addressed-routing.sh`).
65
+
66
+ ## Files
67
+
68
+ ### Plugin (recommended reception unit)
69
+ - `plugin-entwurf-receive/.claude-plugin/plugin.json` — manifest
70
+ - `plugin-entwurf-receive/hooks/hooks.json` — SessionStart(watch) + FileChanged(asyncRewake)
71
+ - `plugin-entwurf-receive/scripts/watch-sessionstart.sh` — arms the **per-session** watchPath (reads session_id from stdin)
72
+ - `plugin-entwurf-receive/scripts/watch-filechanged.sh` — doorbell on external write; mailbox = `dirname(file_path)`, so it reads only its own session
73
+
74
+ ### settings.json reception (alternative; global = every session arms its own watch)
75
+ - `cc-watch-sessionstart.sh` — SessionStart: register per-session watchPath
76
+ - `cc-watch-filechanged.sh` — FileChanged(asyncRewake): active idle-wake doorbell
77
+ - `cc-mailbox-rewake.sh` — Stop(asyncRewake): passive piggyback (next turn boundary), keyed by session_id
78
+
79
+ ### Delivery (sender side, all free file writes, all addressed by sessionId)
80
+ - `cc-enqueue-addressed.sh <session_id> …` — ACTIVE: write `.msg` + poke that session's signal → wakes it from idle
81
+ - `raw-claude-enqueue.sh <session_id> …` — PIGGYBACK: write `.msg` only → delivered at the session's next turn boundary
82
+ - `raw-agy-send.sh <conv_id> …` — agy parity: PUSH into a live Antigravity session (LS gRPC)
83
+ - `raw-codex-ws-turn-start.py <sock> <thread_id> …` — Codex parity (no managed standalone, no cloud): PUSH `turn/start` over WebSocket-over-UDS into a bare `app-server --listen` socket
84
+ - `codex-local-appserver.sh [sock]` — start a bare local app-server so plain `codex` auto-attaches and becomes addressable
85
+
86
+ ### Reproduction drivers
87
+ - `repro-plugin-idle-wake.sh` — single-session smoke.
88
+ - `probe` — deterministic (no tmux): plugin SessionStart hook fires at startup + arms per-session watch.
89
+ - `live` — one session idle → addressed external write wakes it, zero typing.
90
+ - `repro-addressed-routing.sh` — **two sessions** A,B → deliver to A only → assert A wakes, B undisturbed.
91
+
92
+ ## Quick start (plugin reception)
93
+
94
+ ```bash
95
+ # 1. open a live receiving session with the plugin loaded
96
+ export CC_MAILBOX_ROOT=/tmp/cc-mbx
97
+ claude --plugin-dir ./plugin-entwurf-receive --dangerously-skip-permissions
98
+ # (SessionStart hook arms <root>/<session_id>/inbox.signal automatically)
99
+
100
+ # 2. find the target session's id
101
+ cat ~/.claude/sessions/*.json # pick the sessionId you want (match by cwd/pid)
102
+
103
+ # 3. from anywhere else, deliver async to THAT session only (no typing):
104
+ CC_MAILBOX_ROOT=/tmp/cc-mbx ./cc-enqueue-addressed.sh <session_id> "your async message"
105
+ # -> only that session's FileChanged fires -> asyncRewake doorbell
106
+ # -> that idle session wakes, self-fetches body; siblings stay idle
107
+ ```
108
+
109
+ ## Design notes / invariants
110
+
111
+ - **Doorbell only.** `asyncRewake` payload rides **stderr** (stdout is ignored).
112
+ Announce "you have mail" + the body path; never push imperatives — strong
113
+ models flag hook-injected commands as prompt injection. The agent self-fetches.
114
+ - **Body path in the doorbell.** The hook `mv`s `*.msg` → `*.msg.delivered`
115
+ before announcing and reports the `.delivered` path, so the agent reads it in
116
+ one step (measured: removes a filesystem-hunt round-trip).
117
+ - **Free.** Both active and piggyback are file writes + continuation of an
118
+ already-running subscription session. No `claude -p` spawn for the wake.
119
+ (`claude -p` spawn is the metered axis from 2026-06-15; delivery is not.)
120
+ - **Same session / same model.** The wake is an in-process continuation; the
121
+ reply comes from the bound main model (verified `claude-opus-4-8`).
122
+ - **Version-pinned.** Behavior measured on Claude Code **2.1.163**. Re-verify on
123
+ upgrade (claude ships ~weekly; undocumented fields drift).
124
+
125
+ ## Gotchas — hard-won, do NOT re-debug (삽질 방지)
126
+
127
+ Each line below cost real debugging time. If you change this code or port it,
128
+ re-read these first. Two of them were flat-out wrong "impossible" conclusions
129
+ that a second pass reversed.
130
+
131
+ 1. **Stop hook CANNOT wake an IDLE session.** `asyncRewake` on `Stop` fires at a
132
+ *turn boundary*. An idle session has already passed its last `Stop`, so it
133
+ never re-fires on its own — Stop only delivers *piggyback* at the session's
134
+ NEXT turn. The idle active-wake path is **`FileChanged` + `watchPaths`**, not
135
+ `Stop`. (Wrong conclusion #1 was "Claude can't idle-wake" — it was an *event
136
+ selection* error, not a capability limit.)
137
+ 2. **A bare skill cannot arm the watch; a plugin can.** Bare-skill hooks register
138
+ at skill *invocation* (mid-session, after `SessionStart`); plugin
139
+ `hooks/hooks.json` loads at *startup*. (Wrong conclusion #2 was "scoped is
140
+ impossible → global settings only". See top of this file.)
141
+ 3. **`asyncRewake` payload channel is `stderr` ONLY.** Anything on `stdout` is
142
+ dropped and the model sees "No stderr output". The body must go to stderr.
143
+ 4. **`asyncRewake` force-prepends `Stop hook feedback:\n[<script>]:`** and
144
+ **ignores any configured `rewakeMessage`**. You cannot control the exact
145
+ injected string — so use it as a *doorbell* (notify only) and let the agent
146
+ self-fetch the body. Do not depend on injecting the literal message.
147
+ 5. **Infinite-loop guard is mandatory.** Honor `stop_hook_active` (if `true`,
148
+ `exit 0` — already continuing, let it stop) or you get a wake loop. The engine
149
+ also caps re-wakes via `CLAUDE_CODE_STOP_HOOK_BLOCK_CAP`.
150
+ 6. **`watchPaths` can be emitted from only 3 events:** `SessionStart`,
151
+ `CwdChanged`, `FileChanged` (hookSpecificOutput). FileChanged/CwdChanged can
152
+ *dynamically extend* the watch list mid-session; everything else cannot arm a
153
+ watch. This is the whole reason the SessionStart-timing problem matters.
154
+ 7. **Imperatives in an injected message get flagged as prompt injection** by
155
+ strong models (Opus refused). Doorbell = notification framing only.
156
+ 8. **Liveness SSOT is `~/.claude/sessions/<pid>.json`, NOT db-shm/db-wal.** WAL
157
+ files vanish on SQLite checkpoint while the session is still live, and
158
+ reappear on activity — a false "dead/alive" signal. See section below.
159
+ 9. **Cost line: only `claude -p` *spawn* is metered (from 2026-06-15).** The
160
+ wake/delivery is a continuation of an already-running subscription session and
161
+ is free. Don't conflate "launch a new session" with "deliver to a live one".
162
+ 10. **Test-harness quirk:** the Claude TUI sometimes needs a second `Enter` to
163
+ submit a `tmux send-keys` prompt (the first keystroke only fills the input
164
+ box). The repro drivers send `Enter` twice on purpose — not a bug.
165
+
166
+ ## Codex raw delivery status (0.136.0)
167
+
168
+ Measured against `@openai/codex` 0.136.0 (source: `~/repos/3rd/codex`). **Codex IS
169
+ breakable for local raw idle-wake — without managed standalone and without cloud.**
170
+ Split by launch mode, not by "Codex".
171
+
172
+ ### Embedded standalone TUI: negative (and not retrofittable)
173
+
174
+ A plain `codex` TUI launched standalone runs an **Embedded** in-process app-server
175
+ and binds **no external socket** (verified: the live PID has no listening socket;
176
+ its ~2400 inotify watches are project file-tracking for `fs_watch`/`skills_watcher`,
177
+ not a wake trigger). Codex hooks (`SessionStart`, `UserPromptSubmit`, `Pre/PostToolUse`,
178
+ `Pre/PostCompact`, `SubagentStart/Stop`, `Stop`) have **no `FileChanged`/`watchPaths`/
179
+ `asyncRewake`**, so there is no Claude-style filesystem wake either. The
180
+ Embedded/daemon decision is made once at launch (`run_main`), so an already-running
181
+ standalone TUI **cannot be retrofitted**. Identify it via `CODEX_THREAD_ID` /
182
+ `~/.codex/state_5.sqlite` / rollout JSONL, but do not write those as a wake path.
183
+
184
+ ### Local app-server-backed TUI: POSITIVE — D6/D7, DEMONSTRATED
185
+
186
+ A TUI that is backed by a local app-server control socket **is** addressable. Two
187
+ launch shapes both reach it, neither needs the managed standalone or the cloud:
188
+
189
+ - `codex --remote unix://PATH` — explicit attach to an app-server on `PATH`.
190
+ - plain `codex` (no `-c` overrides) — auto-attaches to an app-server already
191
+ listening on the **default** control socket path (`maybe_probe_default_daemon_socket`).
192
+
193
+ Make sessions addressable (see `codex-local-appserver.sh`):
194
+
195
+ ```bash
196
+ # bare app-server on the default control socket — NO managed standalone, NO cloud
197
+ codex app-server --listen unix://$HOME/.codex/app-server-control/app-server-control.sock &
198
+ codex # plain launch auto-attaches; its thread is now reachable
199
+ ```
200
+
201
+ Deliver (WebSocket-over-UDS `turn/start`, no auth — the 0700 socket dir is the boundary):
202
+
203
+ ```bash
204
+ # threadId = newest rollout's session_meta id
205
+ tid=$(head -1 "$(ls -t ~/.codex/sessions/**/rollout-*.jsonl | head -1)" \
206
+ | python3 -c 'import json,sys;print(json.load(sys.stdin)["payload"]["id"])')
207
+ ./raw-codex-ws-turn-start.py "$HOME/.codex/app-server-control/app-server-control.sock" \
208
+ "$tid" "your async message"
209
+ ```
210
+
211
+ Measured (0.136.0, 2026-06-05): idle plain `codex` (auto-attached) woke with **zero
212
+ typing**, the message body was injected, the model replied. `thread/status/changed`
213
+ (`active`→`idle`) notifications give completion observation (D7) on the same socket.
214
+ This is **stronger than Claude Code** — full message injection (like agy), not a
215
+ doorbell + self-fetch.
216
+
217
+ ### Out of scope: the managed-daemon / cloud path
218
+
219
+ This repo ships only the **bare local** path (`codex app-server --listen` +
220
+ `raw-codex-ws-turn-start.py`). There is a second Codex surface we deliberately do
221
+ **not** use: the managed **daemon** control socket driven via `codex app-server
222
+ proxy` (newline JSON-RPC). It needs the managed standalone install
223
+ (`~/.codex/packages/standalone/current/codex`), and `codex remote-control start`
224
+ additionally enables the **cloud** bridge (ChatGPT app access). For a purely-local,
225
+ no-cloud setup the bare `--listen` socket above is sufficient, so no managed-daemon
226
+ sender is shipped here.
227
+
228
+ Method notes: `turn/steer` is active-turn steering (needs `expectedTurnId`), not idle
229
+ wake; `thread/inject_items` appends history without starting a turn; `debug app-server
230
+ send-message-v2` spins a fresh thread, not the live one.
231
+
232
+ ### Gotchas (Codex)
233
+
234
+ 1. **Socket dir mode 0700, owner-owned.** `prepare_private_socket_directory()` chmods
235
+ the socket's parent dir to exactly 0700. Pointing `--listen` at a dir you don't own
236
+ (e.g. `/tmp` directly, mode 1777) fails with **EPERM** — that is the chmod, not a
237
+ capability limit. `~/.codex/app-server-control/` (Codex creates it 0700) is fine.
238
+ 2. **`-c` overrides disable auto-attach.** `can_reuse_implicit_local_daemon` requires
239
+ no `-c`, default loader, no `--strict-config`, no bypass-hook-trust. Launch plain
240
+ `codex` for auto-attach, else use explicit `--remote`.
241
+ 3. **Per-folder trust** (`config.toml [projects."<path>"]`) governs whether a thread
242
+ loads project-local config/hooks/exec-policy — NOT whether it is addressable.
243
+ Addressability is the app-server socket; trust is orthogonal.
244
+ 4. **Unix-socket transport is WebSocket** (tokio-tungstenite), not newline JSON-RPC,
245
+ and requires **no auth token** on the UDS. A plain WS client suffices.
246
+
247
+ ## Live SSOT for "is the target session alive?"
248
+
249
+ - Claude Code: `~/.claude/sessions/<pid>.json` (pid, sessionId, cwd, status). NOT
250
+ db-shm/db-wal — those vanish on WAL checkpoint while the session is still live.
251
+ - agy: `pgrep -x agy` + an LS socket that answers `get-conversation-metadata`.
252
+ - Codex embedded TUI: `CODEX_THREAD_ID` + `~/.codex/state_5.sqlite` +
253
+ `~/.codex/sessions/**/rollout-*.jsonl` identify the live thread/transcript,
254
+ but a standalone Embedded TUI exposes no delivery socket.
255
+ - Codex app-server-backed: an app-server listening on
256
+ `$HOME/.codex/app-server-control/app-server-control.sock` (or any owned 0700
257
+ socket via `codex app-server --listen unix://PATH`) is the delivery surface.
258
+ threadId comes from the newest rollout's `session_meta.id`.
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env bash
2
+ # cc-enqueue-addressed.sh — ADDRESSED async delivery to ONE live Claude Code
3
+ # session, by sessionId. Only that session's per-session watchPath matches, so
4
+ # ONLY that session wakes and processes — siblings stay idle and undisturbed.
5
+ #
6
+ # COST: free (file writes + the target's own subscription-session continuation).
7
+ # LIVE SSOT: ~/.claude/sessions/<pid>.json whose sessionId == <session_id>.
8
+ # PAIRS WITH: watch-sessionstart.sh (per-session watchPath) + watch-filechanged.sh.
9
+ # USAGE: [CC_MAILBOX_ROOT=<root>] cc-enqueue-addressed.sh <session_id> <message...>
10
+ set -euo pipefail
11
+ SID="${1:?session_id (target one specific live session)}"; shift
12
+ CONTENT="${*:?message}"
13
+ ROOT="${CC_MAILBOX_ROOT:-$HOME/.claude/mailbox}"
14
+
15
+ # Verify the target is a live interactive session and surface its identity.
16
+ LIVE=$(python3 - "$SID" <<'PY'
17
+ import json, glob, os, sys
18
+ sid = sys.argv[1]
19
+ for f in glob.glob(os.path.expanduser("~/.claude/sessions/*.json")):
20
+ try:
21
+ d = json.load(open(f))
22
+ except Exception:
23
+ continue
24
+ if d.get("sessionId") == sid:
25
+ print(f"pid={d['pid']} status={d.get('status')} cwd={d.get('cwd')}")
26
+ break
27
+ PY
28
+ )
29
+ [ -n "$LIVE" ] || { echo "session $SID not live (no marker in ~/.claude/sessions/)" >&2; exit 1; }
30
+
31
+ DIR="$ROOT/$SID"; mkdir -p "$DIR"
32
+ TS=$(date -u +%Y%m%dT%H%M%S%N)
33
+ printf '%s\n' "$CONTENT" > "$DIR/$TS.msg"
34
+ printf 'ping %s\n' "$(date -u +%s%N)" >> "$DIR/inbox.signal" # poke ONLY this session's signal
35
+ echo "delivered -> $SID ($LIVE) :: $DIR/$TS.msg"