Skip to content

v0.10.0

Choose a tag to compare

@junghan0611 junghan0611 released this 06 Jun 09:58
· 188 commits to main since this release

The first meta-bridge release (#30): garden-native async delivery into already-running, already-authenticated native coding-agent sessions — Claude Code only. Built bottom-up — a deterministic drift sentinel protecting the (then-undocumented) async-delivery path, the backend-agnostic meta-record authority (schema + pure functions), the idempotent fs upsert, the Claude SessionStart create/attach hook, and entwurf_send mailbox delivery + read-receipt — then the sender/addressee identity path (a native session becomes both wakeable AND a trusted, replyable sender) — and on top of it the operator surfaces: honesty gate (Phase 1), stateful install/uninstall/doctor (Phase 2), the garden-identity statusline (Phase 3), the listing-only meta-store prune janitor (Phase 4), and native sender identity + addressee delivery (Phase 5). A final cut-time fix closed the last identity asymmetry: entwurf_self now resolves trusted meta-session identity too, so a native Claude Code garden citizen can self-report the same replyable garden-id envelope it uses for entwurf_send. Hardening landed in the same cut: the doctor's drift surface is fail-loud again (a set -e early-death regression closed + gated), and #34's level-triggered body-drain robustness basis is now an asserted gate, not just a doc claim. The 0.9.0 evidence-closure entries below also strengthen two live gates so 0.9.0's guarantees are proven directly rather than indirectly and trim a stale follow-up. Scope stays Claude Code only; agy/Codex remain proven future adapters, not shipped surfaces.

Added (0.10.0 meta-bridge — Phase 5 native sender identity + addressee delivery)

  • entwurf_send learns WHO a native session is — authoritatively, not by cwd guess. Phases 1–4 made a native Claude session addressable (the receiver half: wakeable by garden-id, inbox-readable). Phase 5 closes the sender half. A native session sending through the user-scope pi-tools-bridge MCP has no PI_SESSION_ID, so the bridge resolves identity from a sender marker the SessionStart hook writes, keyed by the shared Claude parent pid (process.ppid of the MCP child IS the Claude process the hook ran under — never cwd inference; PI_META_SENDER_MARKER overrides for tests). A trusted marker promotes the send to a replyable meta-session sender addressed by its garden-id. The marker carries ownerPid + a boot-unique ownerStartKey, so a pid-reuse by an unrelated process fails the guard instead of granting a wrong-identity send, and a marker with no backing meta-record is refused. PI_TOOLS_BRIDGE_REQUIRE_META_SENDER=1 (set by the Claude user-scope install) closes the anonymous-send hole: a send with neither pi-session identity nor a trusted marker is refused rather than going out as anonymous external-mcp — "if we don't know who sent it, we don't send it." The sender envelope origin gains "meta-session"; the receiver render shows a [meta-session] badge. Deterministic E2E in smoke-meta-sender-identity (A→B garden-id sender, B→A reply, PPID path, unbacked-record reject, pid-reuse mismatch reject, anonymous reject); the mailbox round-trip (send → enqueue + signal → entwurf_inbox_read.read archive + lastReadAt receipt) is covered E2E by smoke-meta-mailbox. Both gates are in pnpm check.
  • entwurf_resume async-capability discriminant fixed: origin === "pi-session", not replyable alone. A meta-session is entwurf_send-replyable (it owns a garden-id mailbox) but has no pi control socket, so it cannot host an async-resume followUp any more than an external host can. The old auto-resolve keyed on replyable and so routed a meta-session resume into a control-socket lookup that always fails. The async discriminant is now asyncCapable = replyable && origin === "pi-session": a meta-session (like an external host) auto-resolves to sync, and an explicit mode="async" from either is rejected with the canonical reason text. check-async-resume-gate adds the meta-session cases (now 19 assertions).
  • Preventive keyset-overlap guard (check-keyset-overlap + smoke-meta-keyset-guard). The meta-bridge install owns a fixed set of ~/.claude/settings.json (+ ~/.claude.json) keys (SSOT: meta-bridge-state.py managed-keys); agent-config and any future consumer merge their own fragment into the same file. This is the preventive half (doctor's state.py check is the after-the-fact survival half): it fails loud when a consumer fragment collides with a pi-owned key — exact match OR ancestor/descendant — so a later agent-config jq merge (.[0] * .[1], which replaces arrays and overwrites scalars) cannot silently clobber pi-owned policy or vice-versa. smoke-meta-keyset-guard proves a disjoint fragment passes and exact/array/parent-child collisions fail loud while unrelated sibling keys (permissions.defaultMode, language) stay clean; in pnpm check.

Fixed (0.10.0 meta-bridge — cut-time identity symmetry)

  • entwurf_self closes the last pi-only identity gate for garden-native meta-sessions. Phase 5 made native Claude Code sends replyable by garden id, but the introspection twin still used the old strict pi-env path and threw when PI_SESSION_ID / PI_AGENT_ID were absent — even when the same MCP process had a trusted sender marker. entwurf_self now uses the same authoritative identity resolver as entwurf_send: pi sessions return origin="pi-session" + socketPath; trusted meta-sessions return origin="meta-session", agentId="meta-session/claude-code", replyable=true, the garden id, and mailboxPath; plain anonymous external hosts still fail because they have no authoritative reply address. This closes the cut-time asymmetry: a garden-native Claude Code session can now both send as and self-identify as a replyable garden citizen. Regression coverage lives in smoke-meta-sender-identity, and the fix was live-confirmed from agent-config via native Claude Code (entwurf_self returned the session's garden id and replyable: true).

Fixed (0.10.0 meta-bridge — doctor fail-loud drift detail)

  • doctor-meta-bridge surfaces a managed-config drift instead of dying silently. The [managed config state] section captured the state.py check output with a bare CHECK_ERR="$(… check …)" assignment under set -euo pipefail. The instant check exited nonzero (drift), the assignment tripped set -e and the doctor exited 1 after printing only the section header — the very "which key drifted" detail the section exists to print was lost, and every later section ([plugin install], [meta-record store], the SILENT-MISS guard) never ran. The substitution is now the condition of an if, so its nonzero status is consumed (not a death) and the drift detail + the entire rest of the chain always print. A hermetic regression in smoke-meta-install-state forces a real managed-config drift behind a fully-faked claude toolchain (no real claude, no real install) and proves the doctor (a) still exits 1, (b) prints Drift detail:, (c) names the concrete drifted key, and (d) runs to its final summary line — i.e. no early set -e death anywhere. Negative-tested: re-introducing the bare assignment fails the regression on exactly those assertions.

Added (0.10.0 meta-bridge — #34 D8 level-triggered drain gate)

  • check-meta-session asserts the level-triggered body-drain robustness basis (#34). The wake signal (inbox.signal) is edge-triggered — rapid pokes can coalesce into one FileChanged, or a signal can be lost entirely — but each message body is a separate level-state file on disk, so one readMetaInbox drains the whole directory. #34 names this the D8 robustness basis and asks the gate to assert it, not just let DELIVERY.md claim it. A new case enqueues three messages (two fresh .msg + one doorbell-rung .msg.delivered) and proves a single drain returns all of them, in deterministic chronological (sorted) order, with one lastReadAt receipt for the batch, every body archived to .read, and a re-read empty. Negative-tested: an edge-triggered "drain one per signal" regression fails on the all-three assertion while the single-message cases stay green.

Added (0.10.0 meta-bridge — Phase 4 prune listing)

  • ./run.sh meta-bridge-prune — listing-only meta-store janitor. doctor-meta-bridge reds on corrupt JSON / duplicate nativeSessionId / body↔filename drift, but it intentionally does not fail on transcript-gone records — so a green store still silently bloats with abandoned meta-records as native sessions come and go. This separate hygiene surface scans defaultMetaSessionsDir() (override with a positional dir; stale window via --ttl-days, default 30) and classifies every record into orphan (parse OK but transcriptPath no longer exists — a strong abandonment signal, not proof: a backend path migration / cleanup / config-dir change can also vacate it), stale (parse OK, transcript present, lastSeen older than the ttl), ambiguous (corrupt / drift / duplicate — manual-only: the operator decides which authority survives, never a blind rm of a duplicate pair), and keep (live transcript + recent). It prints the exact manual rm commands for orphan/stale candidates and deletes nothing — no --apply in 0.10.0 (this is the conservative "list, the operator removes" scope; actual GC / TTL automation / a global agent-skill wrapper are deferred). Exit 0 on any scannable store (corrupt records are classified, not fatal); a missing store is a clean 0-record listing. The offline smoke-meta-prune gate builds a synthetic store covering every class and proves correct classification, exit 0, and the no-deletion invariant (wording + on-disk file count unchanged); it is in pnpm check.

Added (0.10.0 meta-bridge — Phase 3 statusline)

  • Repo-owned Claude Code statusline with garden identity. scripts/meta-bridge-statusline.sh preserves GLG's existing Claude statusline data (device, shortened cwd with highlighted tail, git branch, model letter, context usage) and renders it as a documented Claude Code multi-line status area: row 1 is <device> <cwd> [branch], row 2 is the meta-bridge truth surface plus runtime summary, 🪛 <garden-id> cc | <model> | <context>. The garden id is resolved on every render by scanning meta-record bodies for the native Claude session_id (nativeSessionId), never by filename, cache, or DB; no match falls back to ?, missing session_id to ready, and duplicate matches to ! while doctor remains the fail-loud surface. Before implementation, the join key was live-measured: a real Claude statusline input carried session_id=f232cc4a-29a9-42d9-8295-e4e3707c0c40, which matched meta-record 20260606T133915-418d94.meta.json by body.
  • Install/doctor now own and verify statusLine. The Phase-2 state manager snapshots/restores settings.json.statusLine, applies the repo-owned statusline command, and checks for drift. doctor-meta-bridge verifies the command path, executability, and a synthetic two-row statusline run; smoke-meta-install-state covers state capture/restore plus statusline garden-id / no-record / no-session / duplicate fallbacks and exact two-row output.

Added (0.10.0 meta-bridge — Phase 2 stateful install/doctor)

  • Stateful Claude Code install/uninstall for the meta-bridge. install-meta-bridge now gates on python3 as a first-class runtime dependency (the FileChanged doorbell parses hook JSON with it), snapshots the operator's pre-install values into ${CLAUDE_CONFIG_DIR:-~/.claude}/pi-shell-acp.install-state.json (mode 0600), then asserts only the repo-owned keyset: enabledPlugins["entwurf-meta-receive@meta-bridge-local"], extraKnownMarketplaces["meta-bridge-local"], USER-scope pi-tools-bridge MCP, the single-driver permissions.allow/deny additions, env.DISABLE_AUTOCOMPACT, and the Claude single-driver scalar policy (cleanupPeriodDays=365, prompt/away/memory/auto-compact/progress/plan-mode toggles pinned off, skipDangerousModePermissionPrompt=true, verbose=false). New uninstall-meta-bridge is the honest inverse: it preflights the state before touching Claude plugin/MCP registrations; scalar/map keys restore their original value or disappear if originally absent; permission arrays remove only the items pi-shell-acp added and preserve user additions. Without state it refuses to guess and performs zero Claude-side removals. A legacy-migration path treats exact pre-Phase-2 plugin/marketplace/MCP values as pi-owned absent values so GLG's already-dogfooded install can uninstall cleanly, while policy keys remain user-owned.
  • Doctor now consumes the Phase-1/2 blockers fail-loud. doctor-meta-bridge validates the state file + managed keyset, fails when meta-bridge-hook.log shows an unrecovered ERROR (only a later INFO armed watch clears a transient miss; degraded UserPromptSubmit record backfill does not; a store-blocked miss that keeps re-logging ERROR stays red — the append-only log never goes stale-red on a one-time, since-healed failure), checks python3, and runs a full meta-record store scan via meta-bridge-store-doctor.ts for corrupt JSON/schema, duplicate nativeSessionId, body↔filename gardenId drift, and backend↔wakeMode contradiction. The offline smoke-meta-install-state gate covers state capture/apply/uninstall, no-state refusal, legacy migration, and store-doctor failure modes, and is now in pnpm check.

Added (0.10.0 meta-bridge — step 3 fs upsert)

  • upsertMetaSession — the idempotent filesystem upsert (#30 step 3). Wraps the step-2 pure core with the real filesystem: mkdir -p the store → readdirscanByNativeIddecideUpsertatomic write (tmp file + rename, mode 0600) so a crash never leaves a half-written record (the #30 "write the record before the session takes over" crash-safety gate). Idempotent end to end: the second call for the same nativeSessionId attaches the same file/garden-id (lastSeen refreshed, no shadow record), and a duplicate nativeSessionId already on disk throws rather than silently picking one. defaultMetaSessionsDir() resolves to <pi-agent-dir>/meta-sessions — honoring PI_CODING_AGENT_DIR (override PI_META_SESSIONS_DIR) so an isolated install/test isolates its meta-records exactly like pi's own sessions, rather than a bare ~/.pi/meta-sessions. The function lives inside meta-session.ts (not a sibling *-store.ts): the typecheck fence forbids a root-config lib importing another .ts lib via a .ts specifier while the .js specifier is unresolvable under node --experimental-strip-types, so a separate store file could not be exercised by the deterministic strip-types gate; only node builtins were added, keeping check-meta-session strip-types clean (now 38 assertions, 5 real-fs temp-dir). The thin CLI/argv shell that invokes this is deferred to step 4, where its stdin contract couples to the Claude SessionStart payload.

Changed (0.10.0 meta-bridge — drift sentinel pin policy)

  • smoke-meta-async-drift pins the backend MAJOR.MINOR line, not the exact patch. Claude ships ~weekly (observed 2026-06-05: 2.1.163 → 2.1.165 the same day, all 9 binary markers unchanged), so an exact-patch pin screamed on every bump and the signal was lost. The version check (A) now compares only major.minor (Claude 2.1.x / codex-cli 0.136.x / agy 1.0.x) and a minor/major move is the real "re-verify markers + Gotchas + raw/LIVE probes" trigger; the binary-marker cross-validation (B) now resolves and scans the actually installed patch binary rather than a hardcoded version path. Patch drift within a pinned minor passes; a minor/major move still screams with exit 1 (negative-tested).

Added (0.10.0 meta-bridge — step 2 record authority)

  • pi-extensions/lib/meta-session.ts + ./run.sh check-meta-session — the meta-record authority (#30 step 2, "record authority FIRST, hook LAST"). A meta-session is the bib card for a native backend session (Claude Code / Antigravity / Codex) that has no pi JSONL of its own: an opaque .meta.json pointer that makes the native session a garden citizen — addressable + wakeable by a garden id — without pretending pi owns its transcript (Hard Rule #8). This step is pure functions + types only (no fs authority, no hook, no CLI — those are steps 3/4), so the schema and the per-backend adapter seam get cut backend-agnostically before any "hook = Claude Code" assumption can ossify. mintMetaRecord stamps a fresh garden id + createdAt==lastSeen + a delivery slot seeded from the backend descriptor; serializeMetaRecord is deterministic (stable key order, 2-space, trailing newline); parseMetaRecord crashes-not-warns on every malformed shape (MetaRecordError), including a backend↔wakeMode contradiction (a Claude record claiming direct-inject, or vice-versa, is corrupt — delivery mode is backend-determined); scanByNativeId is THE lookup authority — it scans record bodies by top-level nativeSessionId (the .meta.json analog of 0.9.0 findSessionFileById), proven against a decoy filename in a real temp dir so it can never regress to filename-parse or a derived index, and it scans to completion and throws on a duplicate nativeSessionId (authority ambiguity is fail-fast, never silently pick one); decideUpsert keys on record existence (idempotent create-then-attach, never a second id) and refuses backend/identity drift. The read-receipt aspect is pre-drilled (delivery.lastEnqueuedAt/lastDeliveredAt/lastReadAt + markEnqueued/markDelivered/markRead) so the later mailbox/send path never touches the schema twice (bbot review #4); the three-backend seam is declared up front (META_BACKENDS + META_BACKEND_DESCRIPTORS with honest wakeMode self-fetch-vs-direct-inject / deliveryLevel). Deterministic gate: 33 assertions, wired into the pnpm check static floor.
  • Garden-id grammar consolidated into a real .js leaf (pi-extensions/lib/session-id.js). SESSION_ID_RE / formatSessionTimestamp / generateSessionId / isValidSessionId moved out of entwurf-core.ts into a dependency-free .js leaf, following the protocol.js pattern (resolvable identically from both the tsc-emit path and the node --experimental-strip-types path — a literal .js specifier that pure unit gates can import, which a .ts sibling import cannot satisfy under strip-types without breaking the root tsc emit). entwurf-core imports and re-exports them, so existing importers are untouched and the id grammar is now a true single source instead of one-copy-per-importer. check-entwurf-session-identity stays 158/158 (no regression).

Added (0.10.0 meta-bridge — step 1 drift sentinel)

  • ./run.sh smoke-meta-async-drift — drift sentinel + capability gate (#30 step 1). The Claude async-delivery path rides on undocumented Claude Code behavior (asyncRewake force-prepends Stop hook feedback: and ignores rewakeMessage; the payload channel is stderr-only; watchPaths arms from only SessionStart/CwdChanged/FileChanged). Claude ships ~weekly, so the path can break silently on any bump. This gate makes it scream instead — direct lineage of the 0.8.x fail-fast tool-surface gates. Two tiers, mirroring smoke-compaction-policy: the deterministic default (offline, free, CI/pre-commit safe) asserts (A) the three backends are on their pinned major.minor lines — Claude 2.1.x / codex-cli 0.136.x / agy 1.0.x (patch is intentionally not pinned: Claude ships ~weekly — observed 2.1.163 → 2.1.165 same day with all 9 markers unchanged — so an exact-patch pin screams every bump and loses the signal; a minor/major move is the real re-verify trigger and does scream; the #30 prose "agy 0.136" was a conflation with codex's version) — and (B) nine undocumented-behavior marker strings are still present in the actually installed Claude binary (binary cross-validation; a marker dropping to zero = the behavior was renamed/removed = the delivery path is dead). LIVE=1 adds (C) the plugin SessionStart watch-arm probe (repro-plugin-idle-wake.sh probe, one metered claude -p). Negative-tested: a moved pin or a vanished marker yields DRIFT DETECTED + exit 1. Not yet wired into release-gate — it asserts on the host's installed Claude binary (environment-dependent), so it stays out of the hermetic pnpm check floor; promotion into the aggregate gate waits for the 0.10.0 cut.

Added (verification docs)

  • DELIVERY.md defines native async-delivery capability levels (D0–D8) for live external sessions. This gives Claude Code, Antigravity/agy, Codex, and pi-native Entwurf a shared diagnostic coordinate system for "can an already-running session receive async work?" without collapsing transport-specific facts into a vague works/doesn't-work claim. Companion raw probes live under scripts/raw-async-delivery/; current evidence records Claude Code FileChanged/watchPaths/asyncRewake idle wake, agy native send-message, and Codex direct-TUI vs app-server split.

Changed (test harness — evidence closure)

  • cross-cwd-resume-smoke now asserts append-not-recreate at the file/id level (T5), not just by recall. The cross-cwd resume gate (verify-resume Phase 2) proved the issue-#9 fix semantically — the sentinel was recalled across the cwd boundary — but never directly asserted that the resume appended to the one true session file rather than silently minting a shadow session in the resumer's cwd. Around the existing recall, the smoke now captures a structural baseline after spawn and re-checks it after resume: (a) exactly one session file carries the header id before and after (no shadow minted anywhere), (b) it is the same file, appended in place (turn count grew), (c) the header id and cwd never drifted (resume authority stays = header, never the resumer's process cwd), and (d) no session for that id exists under the resumer's (wrong) cwd session dir. Live-verified: spawn at a scratch project dir, resume from $HOME, same file appended (turns 1→2), header id/cwd stable, no shadow under the resumer's ($HOME) session dir.
  • smoke-resident-garden-guard now directly proves the resume-into-uuid friendly pre-cancel (0 tokens). The session_before_switch reason "resume" non-garden pre-cancel was previously only backstopped by the session_start hard guard — the friendly path was never exercised on its own. A new RESUME-INTO-UUID section drives an in-process RPC switch_session into a SYNTHETIC legacy-uuid session file (a one-line {type:"session", id:<uuid>} header is enough, because runtime switchSession calls emitBeforeSwitch("resume", path) BEFORE SessionManager.open). It asserts the switch is cancelled (cancelled:true), the friendly "resume is blocked … not garden-native" guidance lands on stderr, the hard guard never fires, 0 tokens (no agent_start), the resident stays on its garden id, and no control socket boots for the uuid. The 0-token sweep is now 30/0 (NEGATIVE + REPLACEMENT + RESUME-INTO-UUID + GNEW).

Removed (follow-up hygiene)

  • Dropped the stale "semantic-memory _entwurf- guidance refresh" follow-up from NEXT.md. agent-config skills/semantic-memory/SKILL.md was already migrated to garden-native discovery in 0.9.0 (no _entwurf- filename species; identity in the JSONL header/name; --session-file-contains reframed as a generic path filter), so the carried item no longer described reality.

Verification

  • Final release-prep evidence before the 0.10.0 cut: pnpm check PASS, then ./run.sh release-gate /tmp/psa-release-gate-0.10.0.EysLWp PASS from log /tmp/pi-shell-acp-release-gate-0.10.0-20260606T184217.logPASS=17 FAIL=0 SKIP=0 with Gemini present. Artifacts: smoke-async-resume /tmp/smoke-async-resume-20260606-184348.json, sentinel /tmp/sentinel-20260606-184958.json (log dir /tmp/sentinel-20260606-184958), session messaging /tmp/session-messaging-smoke-20260606-185236.json.