Skip to content
This repository was archived by the owner on Aug 10, 2026. It is now read-only.

feat: permission relay + launchd plist for channel daemon - #3

Merged
unforced merged 1 commit into
mainfrom
feat/permission-relay-and-launchd
Apr 10, 2026
Merged

feat: permission relay + launchd plist for channel daemon#3
unforced merged 1 commit into
mainfrom
feat/permission-relay-and-launchd

Conversation

@unforced

Copy link
Copy Markdown
Contributor

Summary

  • Permission relay: Bridge receives permission_request notifications from Claude Code, forwards to daemon's new POST /api/permission endpoint, which sends the prompt to all allowlisted Telegram users. Users reply "yes xxxxx" / "no xxxxx"; daemon intercepts the verdict pattern, broadcasts permission_verdict SSE event, bridge forwards back as notifications/claude/channel/permission. Matches the official telegram plugin's protocol.
  • Launchd plist: computer.parachute.channel mirrors the vault daemon's pattern — wrapper script sources PATH + .env, exec's bun. KeepAlive: true for crash restart.

Test plan

  • POST /api/permission sends formatted prompt to Aaron's Telegram
  • Daemon starts via launchd, health check passes
  • End-to-end: Claude Code permission dialog → Telegram prompt → "yes xxxxx" reply → tool approved
  • Verify text replies matching verdict pattern don't also forward as chat messages

🤖 Generated with Claude Code

Permission relay: bridge receives permission_request notifications from
Claude Code, forwards them to the daemon's new POST /api/permission
endpoint, which sends the prompt to all allowlisted Telegram users.
Users reply "yes xxxxx" or "no xxxxx"; the daemon intercepts the
verdict pattern before normal message handling, broadcasts a
permission_verdict SSE event, and the bridge forwards it back to
Claude Code as a notifications/claude/channel/permission notification.

Launchd: wrapper script at ~/.parachute/channel-start.sh and plist at
~/Library/LaunchAgents/computer.parachute.channel.plist mirror the
vault daemon's pattern — source shell profile for PATH, load .env,
exec bun. KeepAlive ensures restart on crash.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@unforced
unforced merged commit a9a7fb3 into main Apr 10, 2026
@unforced
unforced deleted the feat/permission-relay-and-launchd branch April 10, 2026 02:13
unforced added a commit that referenced this pull request Jun 19, 2026
…aths (PR #3)

Six post-audit point-fixes in the load-bearing reply-delivery, claim, and
def-lifecycle paths. The audit flagged silent reply-loss as the scariest
failure mode for an agent module; each fix has a regression test.

FIX 1 (registry.ts) - vault 5xx/network during the outbound write no longer
silently loses the reply. The outbound write now gets a BOUNDED retry (2
retries, linear backoff) on a TRANSIENT error (5xx or no-status network), but
NOT on a 4xx (a real rejection). On a persistent failure the live view
resolves to error (not done) AND the #agent/thread note is RE-RECORDED as
status:error carrying the undelivered reply text - so the durable record never
falsely claims a clean "ok". The claude -p turn is never re-run (no fork/quota
burn); only the idempotent outbound WRITE retries. Backoff base is injectable
for fast tests.

FIX 2 (programmatic.ts) - mid-turn vault-token expiry: ASSESSED + DEFERRED
(documented, no code). The token is minted FRESH per turn at the hub default
~90d TTL, so it cannot expire during a minutes-long turn; and the vault writes
happen inside the opaque claude -p subprocess via the token baked into its
.mcp.json, so the backend has no in-process seam to observe a 401 and re-mint.
A re-mint-on-401 is infeasible at this layer + unnecessary; the real fix (if a
long/short-TTL turn ever makes it real) is MCP-client refresh-on-401, flagged
as a follow-up.

FIX 3 (channel-queue.ts, vault.ts) - claimNext double-claim race. The claim
PATCH is now a COMPARE-AND-SWAP: it carries if_updated_at (the note's
last-seen updated_at); the vault returns 409 (stale precondition) / 428
(precondition required) when the race is lost, surfaced as a typed
InboundClaimConflictError, on which claimNext re-lists and tries the next
pending message instead of double-claiming. updated_at is threaded through
InboundQueueNote. release/handled/sweep keep last-write-wins (force).

FIX 4 (agent-defs.ts) - deleteDef now deletes the vault note FIRST, then
deregisters the in-memory agent (mirrors the agent-vaults removal ordering): a
vault-delete 502 throws before any teardown, leaving the def REGISTERED (it
re-converges on the next poll) instead of orphaned in the confusing
gone-from-memory-but-still-in-vault half-state.

FIX 5 (agent-defs.ts, daemon.ts) - a grant-reconcile failure on def-delete is
no longer silently swallowed. It is still best-effort (does not block the
delete), but now warns loudly AND returns a grantsReconciled:false partial-
success signal so the delete path/route reports that approved hub grants may
be orphaned rather than claiming a clean full success.

FIX 6 (vault.ts) - listInboundQueue no longer lets accumulated handled notes
crowd pending out of the query cap. handled notes are excluded client-side
(only pending + in-flight are the actionable queue) and the vault query is
requested newest-first so a hard cap drops the oldest handled notes, never a
recent pending. status is not indexable per-vault, so a server-side filter is
a future scale optimization.

Gates: typecheck 0 errors; bun test 970 pass / 0 fail; vitest 87 pass / 0 fail.

Closes #101
Closes #96
Closes #103

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
unforced added a commit that referenced this pull request Jun 19, 2026
…ount)

PR #3 reviewer (af47de6b, LGTM-with-nits) caught the one structural nit in FIX 1:
the outbound-failure path called recordThread a SECOND time for the same turn, so
single-threaded re-read turn_count=N and wrote N+1 (the turn counted twice), and
multi-threaded minted a SECOND per-fire note (fresh uuid) for one turn.

Fix: thread a stable per-TURN id (turnThreadId) through every recordThread for the
turn, and a `sameTurn` flag on the failure re-record. writeThread then reuses that
leaf for multi-threaded (one note, not a duplicate) and, for single-threaded with
sameTurn, keeps the existing turn_count instead of incrementing. The note still ends
on status:error with the undelivered reply — just counted once.

Tests: single-threaded same-turn re-record keeps turn_count==1 + flips to error;
multi-threaded re-record reuses the threadId leaf (both writes hit one path).
Declined the 3 cosmetic nits (dead camelCase fallback, two comment-only items).

Gate: bun run test:all -> typecheck 0, bun 972/0, vitest 87/0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
unforced added a commit that referenced this pull request Jun 19, 2026
…ering, grant-GC, queue cap (#116)

* fix(robustness): harden turn-delivery + claim + def-lifecycle error paths (PR #3)

Six post-audit point-fixes in the load-bearing reply-delivery, claim, and
def-lifecycle paths. The audit flagged silent reply-loss as the scariest
failure mode for an agent module; each fix has a regression test.

FIX 1 (registry.ts) - vault 5xx/network during the outbound write no longer
silently loses the reply. The outbound write now gets a BOUNDED retry (2
retries, linear backoff) on a TRANSIENT error (5xx or no-status network), but
NOT on a 4xx (a real rejection). On a persistent failure the live view
resolves to error (not done) AND the #agent/thread note is RE-RECORDED as
status:error carrying the undelivered reply text - so the durable record never
falsely claims a clean "ok". The claude -p turn is never re-run (no fork/quota
burn); only the idempotent outbound WRITE retries. Backoff base is injectable
for fast tests.

FIX 2 (programmatic.ts) - mid-turn vault-token expiry: ASSESSED + DEFERRED
(documented, no code). The token is minted FRESH per turn at the hub default
~90d TTL, so it cannot expire during a minutes-long turn; and the vault writes
happen inside the opaque claude -p subprocess via the token baked into its
.mcp.json, so the backend has no in-process seam to observe a 401 and re-mint.
A re-mint-on-401 is infeasible at this layer + unnecessary; the real fix (if a
long/short-TTL turn ever makes it real) is MCP-client refresh-on-401, flagged
as a follow-up.

FIX 3 (channel-queue.ts, vault.ts) - claimNext double-claim race. The claim
PATCH is now a COMPARE-AND-SWAP: it carries if_updated_at (the note's
last-seen updated_at); the vault returns 409 (stale precondition) / 428
(precondition required) when the race is lost, surfaced as a typed
InboundClaimConflictError, on which claimNext re-lists and tries the next
pending message instead of double-claiming. updated_at is threaded through
InboundQueueNote. release/handled/sweep keep last-write-wins (force).

FIX 4 (agent-defs.ts) - deleteDef now deletes the vault note FIRST, then
deregisters the in-memory agent (mirrors the agent-vaults removal ordering): a
vault-delete 502 throws before any teardown, leaving the def REGISTERED (it
re-converges on the next poll) instead of orphaned in the confusing
gone-from-memory-but-still-in-vault half-state.

FIX 5 (agent-defs.ts, daemon.ts) - a grant-reconcile failure on def-delete is
no longer silently swallowed. It is still best-effort (does not block the
delete), but now warns loudly AND returns a grantsReconciled:false partial-
success signal so the delete path/route reports that approved hub grants may
be orphaned rather than claiming a clean full success.

FIX 6 (vault.ts) - listInboundQueue no longer lets accumulated handled notes
crowd pending out of the query cap. handled notes are excluded client-side
(only pending + in-flight are the actionable queue) and the vault query is
requested newest-first so a hard cap drops the oldest handled notes, never a
recent pending. status is not indexable per-vault, so a server-side filter is
a future scale optimization.

Gates: typecheck 0 errors; bun test 970 pass / 0 fail; vitest 87 pass / 0 fail.

Closes #101
Closes #96
Closes #103

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(thread): re-record the SAME turn on outbound failure (no double-count)

PR #3 reviewer (af47de6b, LGTM-with-nits) caught the one structural nit in FIX 1:
the outbound-failure path called recordThread a SECOND time for the same turn, so
single-threaded re-read turn_count=N and wrote N+1 (the turn counted twice), and
multi-threaded minted a SECOND per-fire note (fresh uuid) for one turn.

Fix: thread a stable per-TURN id (turnThreadId) through every recordThread for the
turn, and a `sameTurn` flag on the failure re-record. writeThread then reuses that
leaf for multi-threaded (one note, not a duplicate) and, for single-threaded with
sameTurn, keeps the existing turn_count instead of incrementing. The note still ends
on status:error with the undelivered reply — just counted once.

Tests: single-threaded same-turn re-record keeps turn_count==1 + flips to error;
multi-threaded re-record reuses the threadId leaf (both writes hit one path).
Declined the 3 cosmetic nits (dead camelCase fallback, two comment-only items).

Gate: bun run test:all -> typecheck 0, bun 972/0, vitest 87/0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant