Skip to content

Release: beta → main (Phase 1 trunk-based migration + watchdog + federation) - #453

Merged
olegbrok merged 27 commits into
mainfrom
beta
May 11, 2026
Merged

Release: beta → main (Phase 1 trunk-based migration + watchdog + federation)#453
olegbrok merged 27 commits into
mainfrom
beta

Conversation

@olegbrok

Copy link
Copy Markdown
Collaborator

Summary

Rollup release bringing beta into main as part of Phase 1 of trunk-based migration (#450).

Highlights from this rollup:

After this merges, main will be the source of truth for the channel-logic cleanup PR + tag cut (26.05.072).

Test plan

  • All CI green on beta (lint, frontend, tests py3.11/3.12/3.13, CodeQL, multi-arch docker build)
  • Confirm CI green on this PR
  • Tag 26.05.072 from main post-merge

🤖 Opened by Barsik

bradbrok and others added 27 commits May 2, 2026 12:54
Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local>
Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local>
Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local>
Restore _resolve_agent_provider() for all runtimes so provider_ref key/model resolution works for Codex agents. Override only resolved_provider_url to codex_cli sentinel after resolution. Extend backfill tests for WHERE-clause defense-in-depth.
…rsistence (closes #361)

Adds a single-register-call test asserting that provider_url, provider_key,
provider_model, provider_ref, thinking_effort, and runtime are persisted through
both the returned Agent object and a subsequent get() DB round-trip.
Covers the INSERT path that silently dropped these fields before PR #358.

https://claude.ai/code/session_01EGnozrh9Fbrp6FMcjDZEnb
Picks up the upstream fixes summarized in #378:

claude-agent-sdk 0.1.73 (claude-code v2.1.122–v2.1.128):
- Sub-agent prompt cache fix (~3× cache_creation reduction)
- MCP reconnection no longer floods conversation with full tool list
- Parallel shell tool sibling-cancellation fix
- MCP image-dropping fix
- EnterWorktree branch-base fix
- OAuth 401 retry-loop fix
- Skill MCP-tools-on-fork fix

anthropic 0.98.1:
- Streaming stop_details propagation fix (#1725)
- Managed Agents API improvements
- Multipart file array field-name fix

Verified: 1700 passed, 1 skipped. No code changes required — pure dep bump.

Heads-up: claude-code v2.1.128 reserves "workspace" as an MCP server name. Relevant to #73 (Pinky Workspace API Server) — pick a different server name.

Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…#383)

* feat(discord): REST-polling inbound poller for per-agent Discord bots

Adds a `BrokerDiscordPoller` analogous to `BrokerTelegramPoller` so agents
can receive Discord messages, not just send them. The Discord REST API has
no equivalent to Telegram's getUpdates long-poll, so this is a periodic
sweep over watched channels (default 1s). A future v0.2 may add a
Gateway/WebSocket variant for true push delivery.

Behavior:
- Auto-discovers text + announcement channels across the bot's guilds at
  startup; can be overridden per-token via settings.watched_channels.
- Primes last_message_id from the most recent message on first sight so
  history is not replayed.
- Filters out the bot's own messages (no self-reply loop).
- Honors 429 rate limits via DiscordRateLimitError.retry_after with a
  bounded retry, then bubbles up so the poll loop can sleep.
- Re-discovers channels every 60s so newly-joined guilds become reachable
  without a daemon restart.

Adapter changes:
- DiscordAdapter now sends a Discord-style User-Agent (default urllib /
  httpx UA gets Cloudflare-403'd on /users/@me).
- _request() catches 429 and retries up to max_429_retries (default 1)
  before raising DiscordRateLimitError. (DiscordRateLimited kept as alias.)
- get_my_guilds() and discover_text_channels() helpers for the poller.

Wiring:
- api.py PUT /agents/{name}/tokens/discord starts/restarts the poller
  the same way the telegram path does, including reading
  poll_interval_sec and watched_channels from token settings.
- DELETE /agents/{name}/tokens/discord stops the poller.
- on_startup auto-spawns Discord pollers for every enabled agent that
  has a saved discord token.

Tests: 11 new (35 total in test_discord*) — UA header, 429 retry/exhaustion,
guild listing, channel-type filtering, broker delivery, bot-self skip,
chronological ordering, empty-channel sentinel handling, discovery
failure tolerance, rate-limit propagation.

Live smoke-tested against bot user Barsik#2361 (id 1501305263351660674):
auth works, poller boots, discovers 0 channels (bot not yet invited to
any guilds), 2 poll sweeps complete cleanly, stop is graceful.

🤖 Opened by Barsik

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* review(discord): address Pushok's PR #383 feedback

- Q2 (BLOCKING for promotion): hoist get_channel out of the per-message
  loop and add a poller-lifetime cache invalidated on each
  _refresh_channels (60s default). Eliminates the burst pathology where
  a chatty channel would fan out to N round-trips per poll. New tests
  cover single-channel-call-per-burst, cache reuse across polls, and
  cache invalidation on rediscovery.
- Q1 (naming): rename `_refresh_channels(prime_last_ids=...)` to
  `_refresh_channels(verbose=...)` — the kwarg only gates logging, not
  priming, and the old name was misleading.
- Q3 (docstring): document that DiscordAdapter is synchronous and that
  async callers must use run_in_executor; mention 429-induced
  time.sleep behavior.
- Drop the DiscordRateLimited/DiscordRateLimitError alias — both were
  introduced in this same PR, no legacy callers, ossifying it now would
  be premature. Keeps DiscordRateLimited as canonical (poller + tests
  already use it) with a noqa N818 since the name reads naturally at
  call sites without the Error suffix.
- Add `add_done_callback` to the broker-delivery `create_task` so
  unhandled exceptions surface in poller logs instead of silently
  warning at the event loop layer. New test asserts the failure log
  line is emitted.
- `asyncio.get_event_loop()` → `asyncio.get_running_loop()` in
  coroutines (modern equivalent; surfaces non-running-loop bugs).

Tests: 39 in test_discord*.py (4 new), still pass; full suite green.

🤖 Reviewed by Pushok, addressed by Barsik

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Telegram clients clear the typing indicator when a bot message arrives,
but the broker's 4s sendChatAction loop kept running until the agent's
turn formally completed via route_response. The next loop iteration would
fire mid-await — making "typing..." reappear *after* the message had
already landed and lingering for the indicator's ~5s TTL.

Cancel the typing loop the moment any outreach send succeeds: text,
voice, photo, document, animation, gif. Reactions intentionally don't
trigger the cancel (the agent may still be drafting a real reply).

Also silence the no-op log line in _stop_typing so defensive calls after
every send don't spam the logs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
When the working tree has local mods to tracked files (e.g. stale
frontend-dist build artifacts from a prior build), git pull aborts.
Recovery previously required out-of-band `git checkout -- <file>` access.

force=True now runs `git checkout -- .` before the pull to discard
local mods to TRACKED files only. Untracked files (.env, local notes)
are preserved — no `git clean`. dry_run is unaffected.

Plumbed through pinky_self update_and_restart MCP tool with a force
kwarg; response surfaces forced_reset + forced_files for visibility.

Closes pinkybot task #77.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…r-notify

Two follow-ups from Brad's first Discord live-verify (PR #386 deploy):

1. Fresh first-test message swallowed by replay-prevention floor.
   When a bot joined a brand-new server and a user sent a test message
   right before the next discovery sweep, that message became the floor
   and was silently skipped — bad first-contact UX.
   Fix in BrokerDiscordPoller._refresh_channels: if the most-recent
   message at prime time is <30s old AND from a non-bot, back the floor
   off by 1 snowflake (Discord IDs are monotonic) so the next poll picks
   it up via `?after=<id-1>`. Bot messages and stale messages keep the
   replay-prevention behavior. Always log the priming with timestamp +
   age so users see exactly what happened.

2. Broker tried to DM owner about new Discord users by sending to the
   user_id as a channel_id → "Discord API error 404: Unknown Channel".
   Discord requires opening a DM channel via POST /users/@me/channels
   first. Fix in DiscordAdapter: new open_dm_channel(user_id) helper,
   plus send_message catches 404 Unknown Channel and transparently
   resolves user_id → DM channel + retries. Resolution is cached per
   recipient so subsequent sends skip the lookup.

7 new tests (4 in test_discord.py, 3 in test_discord_poller.py).
Full suite: 1740 pass, 0 fail. Ruff clean.

Closes pinkybot task #75.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…scape hatch

The admin_update endpoint only ran `pip install` if `.venv/bin/pip` existed,
which silently skipped dependency rebuilds on system-python deployments
(Homebrew/Debian). Result: pyproject pin bumps merged to main but the live
daemon kept running stale package versions.

Fix:
- Fall back to `sys.executable -m pip install --break-system-packages` when
  no in-tree venv is present (PEP 668 externally-managed envs).
- Trigger rebuild when pyproject.toml or uv.lock changed in the pulled
  diff, OR when the new `force_deps=true` query param is set.
- Surface `deps_error` in the response so silent failures stop being silent.
- Wire `force_deps` through the `update_and_restart` MCP tool.

Tests: added 3 cases covering force_deps URL wiring, branch+force_deps
combo, and deps_error surfacing. All 172 pinky_self tool tests pass.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
Adds 4 tests in TestAdminUpdateForceDepsIntegration verifying:
- force_deps=True triggers pip install on a clean (no-op) pull
- force=True + force_deps=True combine correctly (reset + reinstall)
- default behavior skips pip when pyproject.toml is unchanged
- pip install failures surface as deps_error in the response

Mirrors the matching MCP-tool-level coverage in test_pinky_self_tools.py.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
fix(admin): rebuild deps for system-python (rebase of #323 + integration tests)
…ls break (#400)

* feat(auth-alerts): notify operator + surface auth_status when Claude auth breaks

When the Claude SDK returns error='authentication_failed' the streaming
session has always suppressed the response (so raw API errors never reach
end users). The downside: the operator who can re-auth has no signal until
someone complains. This adds two channels for that signal.

1) AuthFailureTracker (auth_alerts.py):
   - Per-host sliding window of auth failures across all agents.
   - Fires an alert at threshold (3 fails in 5 min, OR 3 distinct agents
     failing at once = host-wide outage).
   - 30 min cooldown between alerts to prevent spam.
   - record_success() clears state on the next clean turn.

2) Streaming-session hooks (streaming_session.py):
   - New auth_alert_callback / auth_success_callback ctor params, called
     from the existing assistant-error and ResultMessage paths.
   - _is_auth_error() pattern-matches authentication_failed, invalid_api_key,
     unauthorized, auth_error, permission_error so future SDK renames keep
     alerting.

3) Operator delivery (api.py):
   - _on_auth_failure resolves operator chat (system_settings.operator_chat_id
     first; otherwise the chat_id appearing across the most approved_users
     rows) and DMs via the affected agent's own bot through _broker_send.
   - Codex sessions skip the hook — different auth lifecycle.

4) Health surface:
   - /admin/watchdog gains auth_status with status=ok|degraded|broken,
     window/threshold/cooldown config, outage age, alerts sent, and a
     per-agent failures list. External monitors can alert on broken.

20 new tests covering thresholds, cooldown, window eviction, host-wide
detection, success-clears-state, operator resolution fallbacks, and a
TestClient integration check on /admin/watchdog. Full suite: 1620 passed.

Why now: hit a real outage on the Pi today — all 5 agents auth_failed for
12+ hours and nobody knew because the symptom was "agents stop responding."
This makes the next one a 30-second telegram ping instead of a manual log
grep.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(auth-alerts): preserve cooldown on broker delivery failure + plain-text alert

PR #400 review (Pushok) caught the regression this PR was meant to prevent:
record_failure() advanced _last_alert_at *before* awaiting _broker_send, so
a network blip on the very first send-attempt silenced the alert system for
30 minutes — exactly the Sasha-down-9-12h shape we're designing against.

Two-phase alerting:
- record_failure() now decides only; never mutates cooldown.
- New commit_alert() is invoked by _on_auth_failure ONLY after _broker_send
  succeeds. If delivery raises, cooldown is preserved so the next failure
  retries instead of being lost.

Also fix the markdown parse_mode mismatch Pushok flagged: format_alert_message
used `*agent*` and backticks, but _broker_send defaults to no parse_mode so
operators would see literal `*sasha*`. Switched to plain text — safer than
escaping for MarkdownV2 with arbitrary error strings.

Tracker docstring: clarified concurrency contract (single event loop, no
internal Lock needed) per Pushok's note that "not async-safe" overstated it.

New tests:
- test_failed_delivery_preserves_cooldown_and_retries_next_failure (regression)
- test_commit_alert_advances_cooldown_and_counter
- test_format_alert_uses_no_markdown_so_plain_text_renders_clean
- two existing assertions extended to verify the decide/commit split

Full suite: 1770 passed, 1 skipped. Ruff clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Picks up SDK 0.1.74-0.1.76 fixes on the floor pin: hook event streaming
+ deferred decisions, strict MCP config validation, eager session-store
flushing, permission-suggestions deserialization fix, and bundled CC
2.1.132 (MCP reconnect-flood fix, subprocess-cleanup-on-parent-exit,
CLAUDE_CODE_SESSION_ID env in subprocesses).

Closes the floor-bump action item from #401. The native API-error-status
investigation, subprocess-cleanup verification, and PR #400 auth-alert
re-validation remain as separate follow-ups in #401.

Verification:
- pip install -e . resolves to claude-agent-sdk 0.1.76 (bundled CC 2.1.132)
- pytest: 1770 passed, 1 skipped (parity with pre-bump)

Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…age.api_error_status (#403) (#404)

* refactor(auth-detect): structured AssistantMessage.error + ResultMessage.api_error_status (#403)

Replaces the legacy `_AUTH_ERROR_TOKENS` substring tuple in
streaming_session.py with structured detection on both message paths,
and bumps `claude-agent-sdk` floor 0.1.76 → 0.1.77.

## What changed

**streaming_session.py**
- Drop `_AUTH_ERROR_TOKENS` substring tuple and `_is_auth_error(str)`.
- Add `_is_auth_error_assistant(msg)`: exact match against the
  `AssistantMessageError` Literal (`"authentication_failed"`). Other
  Literal values (billing_error, rate_limit, invalid_request,
  server_error, unknown) are NOT credential failures and must NOT trip
  the operator alert.
- Add `_is_auth_error_result(result)`: detects `api_error_status` in
  {401, 403} on `ResultMessage` (added in SDK 0.1.76, emitted by CLI
  >= 2.1.110). Closes the gap where errors that only land at turn
  completion (not mid-turn on AssistantMessage) were silently
  swallowed by the existing error-result path.
- Update both reader-loop callsites: AssistantMessage path now uses
  the assistant variant; ResultMessage `is_error` path now also fires
  the auth alert callback when status is 401/403.

**api.py**
- Update the `_on_auth_failure` docstring: the comment used to point
  at `auth_alerts._is_auth_error` (which never existed there); now
  references both the assistant and result detectors in
  `streaming_session`.

**tests/test_auth_alerts.py**
- Replace `_is_auth_error(str)` tests with structured tests for both
  detectors using `SimpleNamespace` stand-ins for the SDK message
  types. Coverage:
  - assistant: only `"authentication_failed"` is true; all 5 other
    Literal values are false; `None` and missing-attr are false.
  - result: 401/403 are true; 429 + 5xx are false; `None`/200/missing
    are false.

**pyproject.toml**
- `claude-agent-sdk` floor 0.1.76 → 0.1.77. Required for the
  `api_error_status` field on `ResultMessage` and gets the bundled
  Claude CLI 2.1.133 (subagent skill discovery fix, parallel-session
  refresh-token race fix, hook effort context).

## Why this matters

Pre-refactor, the substring-tuple matcher carried entries
(`invalid_api_key`, `unauthorized`, `auth_error`, `permission_error`)
that the SDK Literal cannot produce — pure cargo from before the
type became strict. Worse, the ResultMessage error path had no auth
detection at all, so credential failures that landed only at turn
completion were silently swallowed.

After this refactor, both paths detect credential failures with
exact-match semantics over typed values, the operator-alert wiring
fires from either path, and we can no longer be silently broken by
an AssistantMessageError variant rename — a mismatch becomes a
test failure instead of a silent regression.

## Test plan

- [x] `pytest tests/test_auth_alerts.py -x` — 27 passed
- [x] Full suite: `pytest tests/ -x` — 1774 passed, 1 skipped
- [x] `ruff check` on modified files — clean

Refs: #401, #402, #403
🤖 Opened by Barsik

* fix(auth-detect): per-turn dedupe + SDK Literal invariant guards

Address Murzik's PR #404 review (P1-ish double-count) and Pushok's
forward-looking concern about silent regression on SDK rename.

## Per-turn auth dedupe (Murzik)

A single failed turn can surface auth errors on BOTH paths the
reader_loop watches:
- AssistantMessage with error="authentication_failed" (mid-turn)
- ResultMessage with api_error_status=401/403 (turn completion)

Without dedupe, AuthFailureTracker.record_failure() increments twice
for one real failure — tripping the operator-alert threshold early
(after ~2 actual failed turns instead of 3) and skewing the host-wide
multi-agent baseline.

Fix: a per-turn `auth_reported_this_turn` flag in the reader_loop.
The AssistantMessage auth path sets it before invoking the callback
(so a callback exception still dedupes). The ResultMessage auth path
gates its callback on `not auth_reported_this_turn`. The flag resets
at the end of each ResultMessage handling — turn boundary.

## SDK Literal invariant (Pushok)

`_is_auth_error_assistant` does exact-match against
`AssistantMessageError`. If a future SDK release renames the Literal
value, exact-match silently stops detecting credential failures —
re-creating the exact regression #400 was built to catch.

Two-layer guard:
- Import-time assertion in reader_loop checks
  `_AUTH_ASSISTANT_ERROR in AssistantMessageError.__args__`. Fails
  loud at session start on any local-dev SDK upgrade that drops the
  literal.
- Unit test in test_auth_alerts.py asserts the same invariant. Fails
  loud at CI on any PR that bumps the SDK across a rename.

## Other changes

- Enrich the ResultMessage auth-callback string with `msg.errors`
  when present — operators see actionable triage context in their
  DM, not just the raw status code (SDK 0.1.77 started returning
  meaningful error messages here).
- Bump uv.lock claude-agent-sdk 0.1.73 → 0.1.77 to match
  pyproject.toml's floor (lock file was stale from PR #402).

## Tests

- 4 new reader-loop regression tests in test_streaming_session.py:
  - both paths emit for one turn → callback fires once
  - result-only path still fires (dedupe doesn't break the gap-fill)
  - dedupe resets between turns (two real failures = two callbacks)
  - non-auth AssistantMessage error doesn't poison next-turn dedupe
- 1 new SDK-Literal-invariant unit test in test_auth_alerts.py.
- test_api.py fake_types stub gains AssistantMessageError so the
  import-time invariant check passes under that test's monkey-patch.

Full suite: 1779 passed / 1 skipped, ruff clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
The Discord poller silently dropped every message authored by any bot,
not just our own. That made cross-fleet messaging impossible — peer
agents like Pulse (Matt's) and Misha (also an agent) were filtered out
before reaching the broker, even when they explicitly addressed us.

The intent of the filter was self-reply-loop prevention; the existing
author_id == self._bot_user_id check at the next line already does
that load-bearing work. The is_bot check was overshooting.

Changes
- pollers.py: drop the blanket is_bot filter; keep self-skip via author_id;
  retain a defensive guard for the (theoretical) is_bot-without-author_id
  case so we never can't-tell-if-it's-us.
- pollers.py priming: use is_self (author_id == bot_user_id) instead of
  is_bot when deciding whether to back off the floor for a fresh first-
  contact message. Peer-bot first-contact during the discovery race now
  delivers the same way a fresh human's first-test message does.
- tests: encode the new contract — self-bot filtered, peer-bot delivered
  (both per-message and during priming); defensive case covered.

Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…#413) (#414)

* feat(ferry): scaffold @ferry/host-pinky with substrate v0.1 ingestion (#413)

Add `pinky_daemon.ferry` package implementing the PinkyBot-side host-callback
for ferry — receives ferry envelopes addressed to PinkyBot agents and
translates them into either:
  - inbound platform messages (via broker.handle_inbound), or
  - substrate-spec memory imports (pinky-memory + pinky-self).

This is the Python implementation of @ferry/host-pinky's TS placeholder
package (choose27/ferry PR #1) and the design doc at packages/host-pinky/README.md.

Wire to pinky-daemon's existing primitives:
  - BrokerMessage(platform="ferry", ...) injection through broker.handle_inbound
    (bypassing human-approval path; peer-fleet ACL enforced upstream)
  - Reflection storage via pinky_memory.MemoryStore.insert
  - Task creation via pinky_self task store

Substrate v0.1 §12.5 unwrap steps implemented:
  1. Verify ferry envelope (stub for v0.1; signature verification deferred)
  2. Populate port_history from envelope.traversal (§6.4 canonicality)
  3. Preserve source.by verbatim (§6.1 — never overwrite with immediate sender)
  4. Type-map per §11 reference impl notes
  5. Index rebuild handled by pinky-memory's normal insert path

Type mapping (host-implementation-defined per §11):
  fact / event / reference  → pinky-memory `fact`
  feedback / decision       → pinky-memory `insight`
  pattern                   → pinky-memory `interaction_pattern`
  pending                   → pinky-self `create_task` (lifecycle states map
                              to task states; cross-MCP integration)

Trust → salience: `salience = 1 + round(trust * 4)` clamped [1, 5]. Documented
as one valid mapping, not the mapping (per Misha + Pulse threads — substrate
stays trust-opaque, hosts decide).

Links preservation: pinky-memory has no native cross-entry edge surface, so
substrate `links` are stored in `context` JSON sidecar. v0.2 §3.2.1
"no-link-surface pattern" reference recipe will formalize the recall-time
fan-out helper.

Peer-fleet ACL primitive (`AgentCardSelector`) — separate identity primitive
from human-user approval. Default-deny. At-least-one-required semantics with
wildcards via `agent_id="*"` and `fleet="*"`.

Tests: 33 passing. Acceptance test uses substrate v0.1 §12 worked example
(two entries, ported A → B) verbatim as fixtures:
  Entry 1: feedback, trust 0.95, scope user/brad — lands as insight, salience 5
  Entry 2: pattern,  trust 0.7,  scope user/brad — lands as interaction_pattern,
           salience 4, with `links` preserved in context sidecar pointing at
           Entry 1's substrate id

Out of scope for v0.1 (tracked in issue #413):
  - NATS leaf-link plumbing (Pulse owns)
  - Outbound from PinkyBot (mcp__pinky-self__send_to_peer_fleet)
  - Multi-agent target routing (broadcast)
  - Real signature verification (accept-all + log)
  - Twice-ported degradation (re_grounded: true reset)
  - Import-time read-quiesce protocol (write-race during demo)

Refs:
  - PinkyBot #413 (this work's behavior contract + acceptance test plan)
  - choose27/ferry PROTOCOL.md v0.1 (envelope shape)
  - choose27/ferry specs/substrate/v0.1.md (entry schema + §12 worked example)
  - choose27/ferry packages/host-pinky/README.md (design doc)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(ferry): peer_fleet_acl persistence + REST API + 20 tests (#413)

Adds the persistence layer + REST API surface that PR #414's host-pinky
needs to enforce peer-fleet ACL. With this in place, the draft can be
unmarked.

agent_registry.py:
  - New `peer_fleet_acl` JSON column on `agents` (default '[]')
  - Migration entry in `_ensure_columns()` migrations list
  - `has_agent(name) -> bool` — efficient existence check
  - `get_peer_fleet_acl(name) -> list[dict]` — read selectors
  - `set_peer_fleet_acl(name, selectors)` — full replacement (drops empty/invalid)
  - `add_peer_fleet_acl(name, fleet=, agent_id=, pinky_type=)` — idempotent append
  - `remove_peer_fleet_acl(name, fleet=, agent_id=, pinky_type=)` — exact-match delete

api.py — new endpoints under `/agents/{name}/peer-fleet-acl`:
  - GET    — list current selectors (404 if agent unknown)
  - POST   — add one selector (400 if all fields empty; idempotent on duplicates)
  - PUT    — full replacement of the selector list
  - DELETE — remove by exact match query (returns count removed)

Pydantic request models:
  - `PeerFleetAclEntryRequest` — single selector for POST/DELETE query
  - `PeerFleetAclSetRequest` — list of selectors for PUT replacement

Identity primitive notes:
  - peer_fleet_acl is *separate* from approved_users. approved_users gates
    humans on Telegram/Discord; peer_fleet_acl gates *agents* arriving via
    ferry. Different identity primitive, different list, different default
    (deny-all for ferry vs auto-approve-primary-user for humans).
  - Empty/invalid selectors are silently dropped at the registry layer with
    a log line; the API layer rejects them with 400 so the caller sees the
    error rather than having a silent no-op.

Tests (20 new, total 53 passing in tests/test_ferry_host_pinky.py):
  - 9 registry persistence tests (set/get roundtrip, replace-not-merge,
    add-idempotent, remove, empty-rejection, type-validation)
  - 2 end-to-end tests against real AgentRegistry (ACL allow/remove cycle,
    real has_agent for unknown-agent rejection)
  - 8 API endpoint tests (GET empty/404, POST adds/rejects-empty/idempotent,
    PUT replaces, DELETE removes/no-match)
  - All ruff-clean; no warnings beyond pre-existing FastAPI deprecations.

Refs:
  - PinkyBot #413 (behavior contract)
  - choose27/ferry packages/host-pinky/README.md (ACL contract section,
    AgentCardSelector shape)
  - Substrate v0.1 §6 source.by preservation invariant relies on this
    ACL working — peer-fleet identity carries source.by claims.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(ferry): close broker-boundary identity leak; round-2 review fixes

PR #414 round-1 wired host-pinky's message dispatch through
broker.handle_inbound, which writes the peer-fleet sender_id into
the human approved_users table and emits /approve_ferry:<peer> as
a Telegram prompt to the owner. The load-bearing identity-primitive
separation claim was broken at the broker boundary.

Round 2 changes:

Blocking fix:
- Add broker.dispatch_pre_authorized(agent_name, message) — public
  primitive that routes a pre-authorized message straight to the
  agent's streaming session, bypassing handle_inbound's onboarding
  flow (get_user_status / add_pending_user / /approve_ prompt).
  Reusable for future pre-authorized channels (federation,
  MCP-host inbound).
- Switch host_pinky._deliver_message to dispatch_pre_authorized;
  rewrite docstring to describe what actually happens at the
  broker boundary (no more vacuous "dedicated path" claim).
- Add TestBrokerIntegrationFerryInbound regression guard: pipes a
  ferry envelope through real Broker + AgentRegistry and asserts
  the ferry sender_id stays out of approved_users, no /approve_
  prompt fires, no message queues under the ferry identity.

Other findings:
- DeliveryResult gains transient_failure status. host_pinky raises
  _TransientACLLoadError on sqlite3.Error from the registry read
  and translates to transient_failure so the broker retries instead
  of treating an operator-side hiccup as a policy denial.
- _insert_task ternary refactored — old form stringified the entire
  task object as a "task id" when .id was empty.
- AgentRegistry add/remove_peer_fleet_acl now guarded by an RLock
  so concurrent admin-API requests can't lose updates on the
  read-modify-write of peer_fleet_acl.
- PortHistoryEntry gains attested_by: "broker" | "receiver" so
  receiver-fabricated synthetic-hop timestamps don't conflate with
  broker-stamped traversal timestamps.
- _load_peer_fleet_acl narrows broad except to (TypeError,
  ValueError, json.JSONDecodeError) for per-item parse skips;
  sqlite3.Error escapes for the transient path.
- HostPinky gains optional reflection_factory injection for tests.
- remove_peer_fleet_acl wildcard-vs-exact semantics documented in
  the docstring + new test pins the behavior.

Tests:
- 4 new test classes: TestTransientACLLoadFailure,
  TestACLRemoveWildcardSemantics, TestDeliverPendingSubstrate,
  TestBrokerIntegrationFerryInbound.
- TestPortHistory now asserts attested_by distinction.
- 58 ferry tests passing; ruff clean.

🤖 Round-2 by Barsik (author) addressing Misha's Class A review.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…418) (#423)

Companion to #414's host_pinky.py inbound. Adds the outbound side: build
canonical ferry envelopes per PROTOCOL.md v0.1 and publish them to the
mesh transport, exposed to agents as the `mesh_remote_send` MCP tool on
pinky-self.

Why shellout (not nats-py):
  host_pinky.py's docstring is explicit that the Python module is the
  *adapter* and "does no transport (NATS / leaf-link plumbing is owned
  by ferry-core)". To keep that boundary clean on outbound, we shell out
  to the `nats` CLI rather than introduce a new in-process transport.
  One-shot publishes match the CLI shape and we proved the wire end-to-end
  with this exact command earlier today (Barsik smoke 2026-05-10 12:14
  PDT, sub-2s, FERRY_MESSAGES seq #3 confirmed by Pulse).

  If we later need a long-lived in-process publisher, MeshSender.send is
  the swap point — callers don't change.

What lands:
- ferry/outbound.py — parse_address (canonical ferry:// + at-form),
  derive_subject (NATS-token sanitization for both fleet + agent),
  allowlist_matches (exact / wildcard / default-deny), build_envelope
  (canonical FerryEnvelope), envelope_to_wire (key rename, optional-field
  elision), MeshSender (env-driven creds, diagnostics, send → SendResult).
- agent_registry.py — `mesh_outbound_allowlist` column + accessors
  mirroring the peer_fleet_acl pattern (get/set/add/remove, _rmw_lock-guarded).
- api.py — `MeshSendRequest` + `MeshAllowlist*Request` Pydantic models,
  `GET/POST/PUT/DELETE /agents/{name}/mesh/allowlist`, `GET /mesh/diagnostics`,
  `POST /agents/{name}/mesh/send`. Default-deny on send.
- pinky_self/server.py — `mesh_remote_send(target, body, kind, …)` tool
  next to `send_to_agent`. Returns JSON `{sent, correlation_id, subject,
  ts, error}`.
- tests/test_ferry_outbound.py — 44 tests across address parsing, subject
  derivation, allowlist matching, envelope building, wire serialization,
  and MeshSender (config / unparseable / happy / nats-failure / timeout /
  password-not-leaked).

Cred handling:
  PINKYBOT_FLEET_USER / PINKYBOT_FLEET_PASSWORD / PINKYBOT_FLEET_NATS_URL
  read from env at MeshSender construction time. Password never appears
  in logs, diagnostics, or error messages (defense-in-depth test).
  PINKYBOT_FERRY_NATS_BIN allows pinning the CLI binary path; falls back
  to `shutil.which("nats")`.

Permission model:
  Per-agent `mesh_outbound_allowlist` (list of `agent@fleet` patterns,
  wildcards supported). Empty list = no outbound. Stops a compromised
  agent from spamming the federation.

Stacked PR:
  Branched off `feat/ferry-host-pinky` to share ferry/types.py + ferry/
  package init. Once #414 merges, rebase to `beta` is a no-op (only the
  inbound files cascade in).

Refs:
- #418 issue body for full design
- ferry PROTOCOL.md v0.1 §1 envelope schema
- packages/host-pinky/README.md design doc
- Smoke proof memory id 09fa81848419

Test plan:
- 44/44 new ferry_outbound tests pass
- 102/102 ferry_host_pinky + agent_registry adjacent tests pass
- 418 tests across all touched modules pass
- ruff clean on all touched files

Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local>
Stacked on PR #421 (mesh_remote_send) which is stacked on PR #414
(host-pinky inbound). Adds the audit log + directory layer.

Two new tables in their own SQLite file (data/pinky_mesh.db, sibling to
the other *_store DBs):

- mesh_messages  — every cross-fleet envelope we send or receive,
                   one row each. Direction-tagged, indexed on
                   (local_agent, received_at) for the UI inbox view
                   and on correlation_id for request/response pairing.
- federation_peers — known remote agents, keyed by (fleet, agent).
                     Tracks first/last seen + seed_source
                     ("config" for pre-configured vs "observed"
                     for auto-registered on first inbound).

Wiring:

- mesh_store.py — MeshStore class, MeshMessage + FederationPeer
  dataclasses. Two tables, three indices. Thread-safe writes via
  RLock.

- api.py — MeshStore instantiated in create_app alongside the other
  *_store singletons. New endpoints:
    GET    /agents/{name}/mesh/inbox  (list w/ kind/sender/direction filters)
    GET    /federation/peers
    POST   /federation/peers           (admin upsert)
    DELETE /federation/peers/{fleet}/{agent}
  Outbound logging wired into existing POST /agents/{name}/mesh/send.
  Persistence failures are caught (defensive) — they must not propagate
  into the API response.

- ferry/host_pinky.py — new optional `mesh_store` kwarg on HostPinky.__init__.
  When set, every deliver() call logs one mesh_messages row with the
  final disposition (success or rejection reason). Logging is wrapped
  in try/except: persistence errors are logged but never break delivery.

Auto-registration: every inbound + every successful outbound touches the
peer's last_seen and registers as seed_source='observed' if new. Failed
outbound does NOT touch the peer (a failed publish doesn't prove
reachability). Config peers are stronger than observed: an upsert with
seed_source='config' promotes an observed peer; subsequent observed
traffic does NOT downgrade a config peer.

Deferred from #419 issue body:
- YAML config seeder (data/federation_peers.yaml at daemon start) — the
  POST /federation/peers admin path covers config-seeding for now;
  a YAML loader is a follow-up if Brad wants startup-time seeding.

Test plan:
- 20 new tests in test_mesh_store.py (schema, log_message inbound/
  outbound success+failure, get_inbox filters/pagination, correlation-id
  lookup, peer upsert/list/get/remove, config-vs-observed promotion
  semantics, defensive deserialization)
- 122 ferry-area tests pass (mesh_store + ferry_outbound + ferry_host_pinky)
- 438 tests pass across all touched modules
- ruff clean

Refs:
- #419 — issue with full design
- #418 / PR #421 — outbound mesh_remote_send (parent of this stack)
- #414 — host-pinky inbound (grandparent)
- #420 — Svelte Mesh tab (next, consumes the new read endpoints)

Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local>
Adds a PreToolUse CLI hook that compares the runtime \$CLAUDE_EFFORT (Claude Code v2.1.133+) against PINKY_EXPECTED_EFFORT (agent's configured thinking_effort, injected by the daemon at session start). On drift the hook fire-and-forget POSTs to /agents/{name}/effort-drift; in strict mode it also emits a decision: block so Claude Code refuses the tool call — except for the remediation tool (set_thinking_effort and MCP variants), which is allowlisted so the agent can self-correct.

What's in:
- agent_registry: strict_effort_enforcement column (default False); effort_drift_events table + record_effort_drift / get_effort_drift_events; hook_verify_effort.py source as a module-level function with strict-mode remediation allowlist; _setup_hooks rewrites the verify_effort script when on-disk content drifts from the daemon's current source so fixes reach existing agents on next session start; idempotent settings.json merge (preserves user hooks); ensure_workspace_hooks() public retrofit method.
- sdk_runner + streaming_session: surface PINKY_AGENT_NAME, PINKY_EXPECTED_EFFORT, PINKY_STRICT_EFFORT through options.env.
- api.py: POST /agents/{name}/effort-drift records the event + heartbeat note + activity entry; GET lists recent events. Streaming-start path calls ensure_workspace_hooks() for retrofit. RegisterAgentRequest + UpdateAgentRequest carry strict_effort_enforcement so per-agent opt-in is reachable from the public API.
- pinky_self: set_thinking_effort MCP tool widened to accept xhigh (mirrors registry validator).

Defaults: warn-only fleet-wide, strict opt-in per agent, no Telegram nag in v1.

Murzik review (3 rounds, all real catches): (1) strict mode blocked the remediation tool — fixed by parsing tool_name from stdin BEFORE deciding to block, with substring allowlist; (2) strict_effort_enforcement wasn't plumbed through the public API request models — fixed; (3) xhigh remediation suggestion was unreachable because SET_EFFORT_ACCEPTED excluded xhigh — fixed, with honest "no self-remediation path" fallback for genuinely-unreachable levels.

Tests: 42 in test_effort_verification.py, all pass. Pre-existing 4 failures around mesh_remote_send unexpectedly in core exist on beta independently of this change.

Unlocks on the pending #416 daemon restart, which is where Claude Code v2.1.138 (\$CLAUDE_EFFORT) actually lives.

Closes #429.
…gents (#431)

* fix(scheduler): stop infinite resurrection loop for connected codex agents

Two-part fix for the murzik resurrection storm where the scheduler kept
firing "resurrection attempt 5/5 for dead agent 'murzik'" against an agent
that was demonstrably online with an active streaming session.

Root cause:
- scheduler._check_heartbeats decided death from latest heartbeat row only,
  ignoring agents.last_seen_at and connected streaming sessions. A month-old
  stale `dead` heartbeat triggered resurrection forever even when the agent
  was clearly alive via streaming presence.
- _heartbeat_resurrect then called ss.is_idle_sleeping and ss.attempt_reconnect
  — Claude's StreamingSession has both; CodexSession had neither. AttributeError
  on every resurrection callback, retry, repeat.

Fix:
1. codex_session.py: implement the StreamingSession watchdog contract —
   _idle_sleeping/is_idle_sleeping property, attempt_reconnect() with bounded
   backoff (2/8/30s), idempotent connect(), stats exposes idle_sleeping.
2. api._heartbeat_resurrect: use getattr fallbacks for is_connected /
   is_idle_sleeping / attempt_reconnect / disconnect / connect. Runtime-
   tolerant — protects future non-Claude runtimes too, not just Codex.
3. scheduler.py: _reconcile_server_liveness() runs before death-handling.
   If a connected streaming session exists or last_seen_at is fresher than
   the heartbeat row (within 2x heartbeat_interval grace), record a synthetic
   `alive` heartbeat with metadata.source=server_presence and clear that
   agent's resurrection attempt counter.

Verification:
- pytest tests/test_codex_session.py tests/test_scheduler.py -q → 76 passed
- pytest tests/test_api.py -q → 272 passed (warnings only)
- ruff check on all 5 files → clean

Tests added:
- test_codex_session.py: pin is_idle_sleeping defaults, idle_sleep sets it,
  connect clears it, disconnect alone does not, attempt_reconnect preserves
  codex_session_id and increments reconnects stat.
- test_scheduler.py: connected streaming session + dead heartbeat → no
  resurrection callback, synthetic alive heartbeat recorded; fresh
  last_seen_at + dead heartbeat → same suppression via fresh_last_seen reason.

Diagnosis and implementation by Murzik. Verified and pushed by Barsik.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test: bump tool-gate count for mesh_remote_send

mesh_remote_send (PR #418/#423) was added as a core tool but the
TestToolGates assertions in tests/test_pinky_self_tools.py were never
updated, leaving CI red on every branch off beta.

- Add "mesh_remote_send" to CORE_TOOLS set
- Bump core count 23 → 24, all-gates total 68 → 69
- Rename the two count-named tests to match

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* feat(boot): main-agent-only boot + auto-deps drift detection

Two related changes from today's restart cycle pain (see also PR #431):

1. **Boot policy**: only the main agent's streaming session and autonomy
   loop start at daemon boot. Sibling agents stay dormant until something
   triggers them — inbound message, agent-to-agent message, or a scheduled
   wake firing. Resources are spent on demand. The main agent decides when
   siblings are needed.

2. **Auto-deps drift detection**: admin_update now compares installed
   package versions to pyproject.toml pins independently of `git diff`,
   so the "pyproject bumped on an earlier pull whose routine restart
   skipped reinstall" case self-heals on the next routine restart. The
   manual `force_deps=True` escape hatch remains for cases drift can't
   see (corrupt install, etc.).

Changes:

- src/pinky_daemon/api.py
  * Startup loop: only the main agent gets a streaming session resumed
    and an autonomy loop started. Drop the `auto_start_agents` loop,
    `agent.heartbeat_interval > 0` trigger, and `has_persisted` branch
    for sibling sessions. Sibling sessions are created lazily via
    `_ensure_streaming_session` on first dispatch.
  * `admin_update`: added `_check_installed_deps_drift()` helper that
    parses pyproject.toml and reports packages whose installed version
    fails the pin (or are missing entirely). Drift entries become a
    third trigger for `pip install`, alongside `pyproject.toml` diff
    and `force_deps=True`. Response now includes `deps_drift` field.
    Drift-check failures (e.g. parse errors) are non-fatal — the other
    reinstall triggers still work.

- src/pinky_daemon/daemon.py
  * Mirror the startup policy: only start the main agent's autonomy
    loop. Sibling agents' loops start lazily via `autonomy.push_event`.

- src/pinky_daemon/autonomy.py
  * `push_event` wake gate now checks `agent.enabled` only, not
    `agent.auto_start`. Gating wake on `auto_start` would have stranded
    sibling agents unable to receive messages under the new boot policy.

Tests:

- tests/test_autonomy.py (new): pin the wake gate — enabled non-auto-start
  agents wake on event; disabled agents do not wake even with auto_start;
  unknown agents are no-ops; already-running loops are not double-started;
  events for blocked-wake agents are still queued (no message drops).

- tests/test_admin_update.py: 9 new tests for `_check_installed_deps_drift`
  and its integration with `admin_update` — empty drift when pins satisfied,
  drift entry when installed < pin, `installed=None` when package missing,
  optional-dependencies inspected, environment markers respected, unparseable
  deps skipped silently, drift triggers reinstall, empty drift + clean diff
  means no reinstall, drift-check raise is non-fatal.

- tests/test_api.py: updated `test_manual_streaming_session_persists_and_restores_labels`
  to mark its test agent as main, since boot-time session restore is now
  scoped to the main agent.

Verification:

- pytest tests/test_admin_update.py tests/test_autonomy.py -q → 25 passed
- pytest tests/test_admin_update.py tests/test_autonomy.py tests/test_scheduler.py
  tests/test_api.py tests/test_codex_session.py -q → 367 passed
- ruff check on all 6 files → clean

Boot-policy rationale: today's force_deps incident exposed how much state
gets restored on every restart cycle. Multiple agents resurrecting in parallel
can interact in pathological ways (cf. murzik resurrection loop in PR #431).
Brad's call: "only main agent on start; you decide the rest." Cleaner failure
mode, less resource churn, simpler debugging.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test: bump tool-gate count for mesh_remote_send

mesh_remote_send (PR #418/#423) was added as a core tool but the
TestToolGates assertions in tests/test_pinky_self_tools.py were never
updated, leaving CI red on every branch off beta.

- Add "mesh_remote_send" to CORE_TOOLS set
- Bump core count 23 → 24, all-gates total 68 → 69
- Rename the two count-named tests to match

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
The session watchdog has been emitting "resurrection attempt 5/5 for
dead agent X" every 30s for every idle-sleeping agent on the Pi fleet
since PR #348/#349 landed. Those PRs added the idle_sleeping check, but
only at the API callback site — by the time it fires, the scheduler
has already consumed a rate-limit budget slot and logged the attempt.
With 7 agents idle-sleeping most of the time, the journal fills with
noise and Brad reports it "feels too eager."

Root cause: _maybe_resurrect's only gate was the rate limit. The API
callback would refuse the actual reconnect, but the scheduler had no
way to know that ahead of time, so it kept burning budget + log slots.

Fix: add is_resurrectable_fn precondition to AgentScheduler. Called
before budget/log/callback. If it returns False, return silently —
no budget consumed, no log emitted, no callback fired. api.py wires
in _is_resurrectable which returns False when the streaming session
is idle_sleeping (or missing entirely).

Fails open on precondition exceptions so a buggy resolver can't
silently disable resurrection for the whole fleet.

Tests:
- precondition False → callback never fires, budget stays at 0
- precondition True → original behavior preserved
- precondition raises → falls through (fail-open)

Pi was hot-patched in parallel (early-return in _maybe_resurrect) to
quiet the noise immediately; this PR replaces that with the proper
fix, so the next deploy cleanly overwrites the hot-patch.

Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Resolves the divergence between beta and main caused by hotfixes landing
straight on main (#416 SDK floor, #411 discord backport, #407 Docker,
#408/#398/#397 broker fixes, #406 release/auth-alerts). Beta had 25
commits not on main and main had 10 commits not on beta.

Conflict resolution:

- pyproject.toml: claude-agent-sdk >=0.1.80 (main's bump wins)
- agent_registry.py: kept beta additions (peer_fleet_acl, mesh_outbound_allowlist,
  strict_effort_enforcement) layered onto main's structure
- api.py: ~85 inline BaseModel class defs (beta legacy) removed in favor of
  main's api_models.py refactor. Added 7 beta-only classes to api_models.py:
  EffortDriftRequest, FederationPeerUpsertRequest, MeshAllowlistEntryRequest,
  MeshAllowlistSetRequest, MeshSendRequest, PeerFleetAclEntryRequest,
  PeerFleetAclSetRequest
- api_models.py: RegisterAgentRequest + UpdateAgentRequest gained
  strict_effort_enforcement field (PR #429)
- send_voice / send_gif / send_document handlers: took main's outreach-outcome-enum
  versions (rejected/error_upstream/error_internal buckets) over beta's
  simpler error handling
- /admin/update: kept beta's #432 auto-deps drift detection
  (_check_installed_deps_drift) on top of main's deps install logic
- tests/test_admin_update.py: kept beta's TestInstalledDepsDriftDetection

Local test run: 1835 passed (pinky_federation skipped — pynacl not installed
on dev machine).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ruff --fix dropped 40 unused imports from `from pinky_daemon.api_models`.
These came from the over-inclusive import I added when merging — the
api_models refactor only used ~40 of the 84 model classes; the rest were
referenced inline (now removed). Pushok caught it on the failing CI lint.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
# and getting the latest semantics on disk matters: e.g. the
# remediation-tool allowlist fix shipped after the initial cut.
existing_source = (
verify_effort_path.read_text() if verify_effort_path.exists() else ""
# and getting the latest semantics on disk matters: e.g. the
# remediation-tool allowlist fix shipped after the initial cut.
existing_source = (
verify_effort_path.read_text() if verify_effort_path.exists() else ""
)
new_source = _verify_effort_hook_source()
if existing_source != new_source:
verify_effort_path.write_text(new_source)

AgentRegistry._sync_hooks_settings(
claude_dir / "settings.json",
working_path=working_path.resolve(),
AgentRegistry._sync_hooks_settings(
claude_dir / "settings.json",
working_path=working_path.resolve(),
idle_path=idle_path.resolve(),
claude_dir / "settings.json",
working_path=working_path.resolve(),
idle_path=idle_path.resolve(),
verify_effort_path=verify_effort_path.resolve(),
# present. Match on the absolute script path so we don't dup if the
# user re-pathed it manually.
try:
data = _json.loads(settings_path.read_text())
target_bucket.setdefault("hooks", []).append(
{"type": "command", "command": verify_cmd}
)
settings_path.write_text(_json.dumps(data, indent=2) + "\n")
_log(f"codex[{self.agent_name}]: reconnect attempt {attempt_idx} failed: {e}")
try:
await self.disconnect()
except Exception:
# Patch out the real Reflection import inside the host_pinky module so
# we can run the test without the pinky_memory dependency providing a
# concrete model. The test substitutes our FakeReflection.
import pinky_daemon.ferry.host_pinky as host_pinky_mod
@olegbrok
olegbrok merged commit a96f7c5 into main May 11, 2026
17 of 18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Reconcile beta↔main divergence (hotfixes on main not in beta)

4 participants