@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.
- package/AGENTS.md +240 -0
- package/BASELINE.md +227 -0
- package/CHANGELOG.md +1210 -0
- package/CONTRIBUTING.md +63 -0
- package/DELIVERY.md +209 -0
- package/LICENSE +21 -0
- package/README.md +504 -0
- package/VERIFY.md +260 -0
- package/demo/README.md +188 -0
- package/demo/demo-baseline.sh +156 -0
- package/demo/demo.sh +183 -0
- package/docs/assets/entwurf-demo.gif +0 -0
- package/docs/assets/entwurf-doomemacs.gif +0 -0
- package/docs/assets/entwurf-entwurf.gif +0 -0
- package/docs/assets/entwurf-hero.jpg +0 -0
- package/docs/setup-clean-host.md +305 -0
- package/mcp/entwurf-bridge/src/index.ts +513 -0
- package/mcp/entwurf-bridge/start.sh +25 -0
- package/mcp/entwurf-bridge/test.sh +54 -0
- package/mcp/tsconfig.json +29 -0
- package/package.json +130 -0
- package/pi/entwurf-capabilities.json +9 -0
- package/pi/entwurf-targets.json +20 -0
- package/pi/meta-bridge/.claude-plugin/marketplace.json +12 -0
- package/pi/meta-bridge/entwurf-meta-receive/.claude-plugin/plugin.json +5 -0
- package/pi/meta-bridge/entwurf-meta-receive/hooks/hooks.json +50 -0
- package/pi/meta-bridge/entwurf-meta-receive/scripts/doorbell.sh +54 -0
- package/pi/settings.reference.json +43 -0
- package/pi/skill-plugin-example/.claude-plugin/plugin.json +4 -0
- package/pi/skill-plugin-example/skills/hello/SKILL.md +18 -0
- package/pi-extensions/acp-provider.ts +63 -0
- package/pi-extensions/entwurf-control.ts +1692 -0
- package/pi-extensions/lib/acp/acp-client.ts +90 -0
- package/pi-extensions/lib/acp/augment.ts +238 -0
- package/pi-extensions/lib/acp/backend-adapter.ts +319 -0
- package/pi-extensions/lib/acp/backend.ts +873 -0
- package/pi-extensions/lib/acp/config.ts +552 -0
- package/pi-extensions/lib/acp/context.ts +177 -0
- package/pi-extensions/lib/acp/engraving.ts +123 -0
- package/pi-extensions/lib/acp/event-mapper.ts +339 -0
- package/pi-extensions/lib/acp/models.ts +102 -0
- package/pi-extensions/lib/acp/overlay.ts +220 -0
- package/pi-extensions/lib/acp/prompts/engraving.md +1 -0
- package/pi-extensions/lib/acp/session-store.ts +418 -0
- package/pi-extensions/lib/acp/tool-surface.ts +184 -0
- package/pi-extensions/lib/entwurf-control-rpc.ts +208 -0
- package/pi-extensions/lib/entwurf-core.ts +2033 -0
- package/pi-extensions/lib/entwurf-deliverability.ts +123 -0
- package/pi-extensions/lib/entwurf-fact-provider.ts +158 -0
- package/pi-extensions/lib/entwurf-facts.ts +251 -0
- package/pi-extensions/lib/entwurf-mailbox-guard.ts +100 -0
- package/pi-extensions/lib/entwurf-peers-render.ts +154 -0
- package/pi-extensions/lib/entwurf-preflight.ts +247 -0
- package/pi-extensions/lib/entwurf-resume-args.ts +88 -0
- package/pi-extensions/lib/entwurf-self-address.ts +117 -0
- package/pi-extensions/lib/entwurf-v2-contract.ts +427 -0
- package/pi-extensions/lib/entwurf-v2-decider.ts +441 -0
- package/pi-extensions/lib/entwurf-v2-lock.ts +412 -0
- package/pi-extensions/lib/entwurf-v2-mailbox.ts +87 -0
- package/pi-extensions/lib/entwurf-v2-production.ts +326 -0
- package/pi-extensions/lib/entwurf-v2-release.ts +149 -0
- package/pi-extensions/lib/entwurf-v2-resume-marker.ts +34 -0
- package/pi-extensions/lib/entwurf-v2-runner.ts +196 -0
- package/pi-extensions/lib/entwurf-v2-send-fallback.ts +166 -0
- package/pi-extensions/lib/entwurf-v2-send.ts +271 -0
- package/pi-extensions/lib/entwurf-v2-spawn-production.ts +337 -0
- package/pi-extensions/lib/entwurf-v2-spawn.ts +323 -0
- package/pi-extensions/lib/entwurf-v2-surface.ts +205 -0
- package/pi-extensions/lib/meta-mailbox-body.ts +80 -0
- package/pi-extensions/lib/meta-session.ts +1951 -0
- package/pi-extensions/lib/project-trust-handler.ts +154 -0
- package/pi-extensions/lib/session-id.js +57 -0
- package/pi-extensions/lib/socket-discovery.ts +346 -0
- package/pi-extensions/lib/socket-probe.ts +92 -0
- package/pi-extensions/meta-bridge-hook.ts +250 -0
- package/pi-extensions/model-lock.ts +235 -0
- package/prompts/engraving.md +27 -0
- package/protocol.js +31 -0
- package/run.sh +2832 -0
- package/scripts/check-acp-backend-preflight.ts +130 -0
- package/scripts/check-acp-carrier-augment.ts +297 -0
- package/scripts/check-acp-config.ts +322 -0
- package/scripts/check-acp-event-mapper.ts +253 -0
- package/scripts/check-acp-overlay.ts +154 -0
- package/scripts/check-acp-prompt-builder.ts +191 -0
- package/scripts/check-acp-provider-surface.ts +159 -0
- package/scripts/check-acp-sdk-surface.ts +211 -0
- package/scripts/check-acp-session-reuse.ts +756 -0
- package/scripts/check-acp-session-store.ts +387 -0
- package/scripts/check-acp-tool-surface.ts +159 -0
- package/scripts/check-entwurf-bridge-boot.ts +199 -0
- package/scripts/check-entwurf-capabilities.ts +123 -0
- package/scripts/check-entwurf-control-rpc.ts +187 -0
- package/scripts/check-entwurf-deliverability.ts +158 -0
- package/scripts/check-entwurf-fact-provider.ts +316 -0
- package/scripts/check-entwurf-facts.ts +358 -0
- package/scripts/check-entwurf-mailbox-guard.ts +264 -0
- package/scripts/check-entwurf-peers-surface.ts +235 -0
- package/scripts/check-entwurf-resume-args.ts +149 -0
- package/scripts/check-entwurf-self-address.ts +209 -0
- package/scripts/check-entwurf-session-identity.ts +703 -0
- package/scripts/check-entwurf-v2-contract.ts +536 -0
- package/scripts/check-entwurf-v2-decider.ts +795 -0
- package/scripts/check-entwurf-v2-lock.ts +368 -0
- package/scripts/check-entwurf-v2-mailbox.ts +228 -0
- package/scripts/check-entwurf-v2-matrix.ts +437 -0
- package/scripts/check-entwurf-v2-production.ts +406 -0
- package/scripts/check-entwurf-v2-release.ts +245 -0
- package/scripts/check-entwurf-v2-runner.ts +452 -0
- package/scripts/check-entwurf-v2-send-fallback.ts +371 -0
- package/scripts/check-entwurf-v2-send.ts +454 -0
- package/scripts/check-entwurf-v2-spawn-production.ts +452 -0
- package/scripts/check-entwurf-v2-spawn.ts +398 -0
- package/scripts/check-entwurf-v2-surface.ts +352 -0
- package/scripts/check-keyset-overlap.py +120 -0
- package/scripts/check-mailbox-receipt-state.ts +170 -0
- package/scripts/check-meta-capability-source.ts +112 -0
- package/scripts/check-meta-dual-consumers.ts +154 -0
- package/scripts/check-meta-dual-read.ts +158 -0
- package/scripts/check-meta-listing.ts +138 -0
- package/scripts/check-meta-mailbox-state-write.ts +135 -0
- package/scripts/check-meta-migration.ts +212 -0
- package/scripts/check-meta-receiver-marker.ts +185 -0
- package/scripts/check-meta-record-v2.ts +191 -0
- package/scripts/check-meta-session.ts +673 -0
- package/scripts/check-model-lock.ts +408 -0
- package/scripts/check-package-source-routing.ts +253 -0
- package/scripts/check-pi-preflight.ts +304 -0
- package/scripts/check-project-trust-handler.ts +265 -0
- package/scripts/check-shell-quote.ts +121 -0
- package/scripts/check-socket-discovery.ts +428 -0
- package/scripts/check-socket-probe.ts +106 -0
- package/scripts/fixtures/probe-mcp-server.ts +33 -0
- package/scripts/gnew-rpc-drive.ts +211 -0
- package/scripts/lib/acp-child-cleanup.ts +116 -0
- package/scripts/meta-bridge-doctor.sh +315 -0
- package/scripts/meta-bridge-hook-log.sh +26 -0
- package/scripts/meta-bridge-install.sh +135 -0
- package/scripts/meta-bridge-prune.ts +199 -0
- package/scripts/meta-bridge-state.py +549 -0
- package/scripts/meta-bridge-statusline.sh +192 -0
- package/scripts/meta-bridge-store-doctor.ts +64 -0
- package/scripts/meta-bridge-uninstall.sh +39 -0
- package/scripts/new-session-id.ts +25 -0
- package/scripts/postinstall-chmod.cjs +58 -0
- package/scripts/raw-async-delivery/README.md +258 -0
- package/scripts/raw-async-delivery/cc-enqueue-addressed.sh +35 -0
- package/scripts/raw-async-delivery/cc-mailbox-rewake.sh +38 -0
- package/scripts/raw-async-delivery/cc-watch-filechanged.sh +19 -0
- package/scripts/raw-async-delivery/cc-watch-sessionstart.sh +15 -0
- package/scripts/raw-async-delivery/codex-local-appserver.sh +39 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/.claude-plugin/plugin.json +5 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/hooks/hooks.json +28 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-filechanged.sh +28 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-sessionstart.sh +27 -0
- package/scripts/raw-async-delivery/raw-agy-send.sh +29 -0
- package/scripts/raw-async-delivery/raw-claude-enqueue.sh +30 -0
- package/scripts/raw-async-delivery/raw-codex-ws-turn-start.py +164 -0
- package/scripts/raw-async-delivery/repro-addressed-routing.sh +96 -0
- package/scripts/raw-async-delivery/repro-plugin-idle-wake.sh +104 -0
- package/scripts/resolve-acp-bridge.ts +25 -0
- package/scripts/smoke-acp-bundled-mcp-live.ts +292 -0
- package/scripts/smoke-acp-carrier-augment-live.ts +192 -0
- package/scripts/smoke-acp-mcp-live.ts +129 -0
- package/scripts/smoke-acp-memory-containment-live.ts +389 -0
- package/scripts/smoke-acp-overlay-live.ts +314 -0
- package/scripts/smoke-acp-provider-live.ts +162 -0
- package/scripts/smoke-acp-raw-turn-live.ts +261 -0
- package/scripts/smoke-acp-session-reuse-live.ts +172 -0
- package/scripts/smoke-acp-skill-live.ts +144 -0
- package/scripts/smoke-acp-socket-citizen-live.ts +168 -0
- package/scripts/smoke-claude-native-resume-live.sh +198 -0
- package/scripts/smoke-entwurf-v2-matrix-live.ts +398 -0
- package/scripts/smoke-entwurf-v2-spawn-live.ts +175 -0
- package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +428 -0
- package/scripts/smoke-meta-async-drift.sh +171 -0
- package/scripts/smoke-meta-honesty.sh +147 -0
- package/scripts/smoke-meta-install-state.sh +403 -0
- package/scripts/smoke-meta-keyset-guard.sh +111 -0
- package/scripts/smoke-meta-prune.sh +174 -0
- package/scripts/smoke-resident-garden-guard.sh +433 -0
- package/scripts/smoke-session-id-name.ts +187 -0
- 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"
|