Commit Graph

42 Commits

Author SHA1 Message Date
glifocat
3d6837c411 chore(format): apply prettier to chat-sdk-bridge.ts
Two long-line violations introduced in d121cd1 (isGroup plumbing)
exceed the printWidth limit. CI format:check fails on every PR
opened against main until this is fixed; the fix is isolated here
so no behavior change is mixed in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 12:12:05 +02:00
gavrielc
c1d0395d11 Merge branch 'main' into main 2026-04-23 23:04:35 +03:00
exe.dev user
ff277c0d49 fix(chat-sdk-bridge): encode option index in callback_data for Telegram 64-byte cap
ask_question cards failed to deliver on Telegram whenever any option had
a non-trivial value (e.g. an ISO datetime, a URL, or a long token).
Telegram limits inline-keyboard callback_data to 64 bytes, and the
previous encoding embedded both the questionId and the full option
value in each button's actionId plus a second copy as value, producing
payloads well over the cap. The adapter threw ValidationError, delivery
was marked permanently failed, and the agent sat waiting on an answer
that never reached the user.

Fix:
  - Button id is now `ncq:<questionId>:<index>` and button value is the
    stringified index. Callback payloads shrink from ~100 bytes to ~40
    and fit Telegram's cap for any option list with <100 items.
  - Both callback-decode sites (Chat SDK `onAction` for Telegram/Slack/
    etc., and the Discord Gateway interaction handler) resolve the
    index back to the real option value via
    `getAskQuestionRender(questionId)` before dispatching to the host's
    onAction — so response handlers (pending_questions, pending_approvals)
    are unchanged and still receive the canonical value.
  - `resolveSelectedOption` helper has a backward-compat fallback:
    non-numeric tails are treated as literal values so any card
    delivered under the old encoding still resolves if the user clicks
    it after deploy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 16:56:21 +00:00
exe.dev user
61ca43d193 fix(discord): resolve user ID from DM interactions for approval clicks
Discord puts the clicking user at interaction.member.user for guild
interactions but interaction.user for DM interactions. The Gateway
handler only checked interaction.member, so DM button clicks resolved
to an empty user ID and were silently rejected as unauthorized.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 12:23:12 +00:00
Dave Kim
91c668e0cc fix: persist SDK session_id on init + split long messages before adapter truncation
Two related bugs that surfaced together when a Discord response exceeded
2000 chars:

1. **Session id lost on mid-turn container exit.** `runPollLoop` was
   calling `setStoredSessionId` only after `processQuery` returned. If
   the container died between the SDK's `init` event (where session_id
   arrives) and the stream completing, the id was never persisted. The
   next wake called `getStoredSessionId()` → undefined and started a
   fresh Claude session, dropping all prior context. Fix: persist
   immediately in the `init` branch inside `processQuery`. The existing
   post-query store becomes a harmless no-op.

2. **Silent truncation past adapter limits.** `chat-sdk-bridge.deliver`
   handed full text straight to `adapter.postMessage`. Discord's adapter
   hard-truncates at 2000 chars; Telegram's at 4096. Responses longer
   than that were cut off without any signal to the user or host. Fix:
   add `maxTextLength` to `ChatSdkBridgeConfig` and a `splitForLimit`
   helper that breaks on paragraph → line → hard-char boundaries, then
   posts chunks sequentially. Files ride on the first chunk; the
   returned id is the first chunk's so edits and reactions still target
   the reply head.

Channel adapter files (Discord, Telegram, …) live on the `channels`
branch — a companion PR wires `maxTextLength: 1900` for Discord and
`4000` for Telegram so the splitter actually engages in those installs.
Without wiring, behavior is unchanged.
2026-04-21 13:04:57 +00:00
gavrielc
a4061a0012 refactor(channels,router): move all policy to router; bridge is transport
Follow-up to b159722. That shrank the bridge's shouldEngage to a flood
gate + coarse sticky-subscribe signal. This completes the move —
policy lives exclusively in the router, the bridge is transport-only,
and the conversations map + ChannelSetup.conversations +
ChannelAdapter.updateConversations are all gone.

Key shifts:

1. Subscribe moves from bridge to router.

   Bridge used to call `thread.subscribe()` from its onNewMention /
   onDirectMessage handlers based on a coarse "any mention-sticky wiring
   exists on this channel" check. That forced the decision before the
   router could apply per-wiring engage logic, and it relied on the
   conversations map being current (staleness risk).

   ChannelAdapter gains `subscribe?(platformId, threadId)`. The Chat
   SDK bridge implements it via SqliteStateAdapter.subscribe(threadId)
   (idempotent — a repeat call on an already-subscribed thread is a
   no-op). The router's fan-out loop calls it once per message when
   the first mention-sticky wiring actually engages. Precise, not
   coarse.

2. Short-circuit the drop path with one combined query.

   New `getMessagingGroupWithAgentCount(channelType, platformId)` does
   the messaging_groups lookup AND counts wirings in a single SELECT,
   using the existing UNIQUE(channel_type, platform_id) index on
   messaging_groups and UNIQUE(messaging_group_id, agent_group_id) on
   messaging_group_agents for the JOIN. No new indexes needed.

   routeInbound now short-circuits:
     - No messaging_groups row AND not addressed (no mention/DM)
       → return silently. One DB read, nothing written. This is the
       Discord-bot-in-a-big-guild case; we no longer auto-create rows
       for every plain message in every channel the bot can see.
     - Messaging group exists but no wirings AND not addressed
       → return silently. One DB read.
     - Otherwise fall through to sender resolution + fan-out as before.

   Behavioral change: plain chatter on unwired channels no longer gets
   dropped_messages audit rows, which used to bloat the table. Audit
   still fires on addressed-to-bot drops where the admin cares
   ("someone @-mentioned us but nobody's wired").

3. Bridge is now purely transport.

   Deleted entirely: ConversationConfig, ChannelSetup.conversations,
   ChannelAdapter.updateConversations?, bridge's `conversations` map,
   buildConversationMap, shouldEngage, EngageSource, engageDecision,
   bridge.updateConversations method, src/index.ts
   buildConversationConfigs. Four handlers reduce to "resolve channel
   id, build InboundMessage with isMention, call onInbound". Net
   ~130 LOC deleted from the bridge.

   Collateral: the conversations-map staleness problem is gone. The
   upcoming channel-registration feature doesn't need any map-refresh
   plumbing — when an approval creates a new wiring, the next message
   hits the DB fresh and just works.

Bridge tests prune to the narrow platform-adjacent surface (openDM
delegation, subscribe presence). Host-core test that asserted the
old "auto-create on every unknown message" behavior updates to
reflect the new escalation-gated semantics: plain messages on
unknown channels don't auto-create, mentions do.

159 tests pass (was 172 — net -13, almost entirely from
bridge-engage-mode tests that covered logic now owned by the router
and exercised through host-core.test.ts).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 13:55:49 +03:00
gavrielc
b15972284b refactor(channels): shrink bridge shouldEngage to flood gate + subscribe signal
Before this change the bridge and the router both owned engage_mode
policy. Bridge's shouldEngage had a full switch over mention /
mention-sticky / pattern + source-based rules + engage_pattern regex
test + ignored_message_policy accumulate fallback. Router's
evaluateEngage had the same switch against the same fields. Two
parallel logic paths with subtle vocabulary differences (bridge: "which
SDK handler fired"; router: "what isMention says"). Every time we
touched one we had to reason about the other — the Telegram
hasMention bug and the "pattern mode silently drops in group chats"
bug were both drift between the two.

Refactor to one place. Router keeps all per-wiring policy — engage
mode, pattern regex, sender scope, ignored-message policy — unchanged.
Bridge drops to a coarse flood gate + subscribe signal:

  - forward: does this channel have ANY wiring? Forward if yes.
    Unknown channels still forward for subscribed/mention/dm (they may
    be newly auto-created, or will trigger the coming
    channel-registration flow). Unknown channels DROP for new-message
    so we don't flood from every unsubscribed thread the bot happens
    to sit in.

  - stickySubscribe: any mention-sticky wiring on the channel AND the
    source is mention or dm. Coarse union — subscribe is idempotent
    and one call serves every sticky wiring.

The `text` param on shouldEngage is gone (pattern regex lives in the
router now). Four bridge handler sites simplify accordingly. messageToInbound
still carries the SDK-confirmed isMention flag through to the router
unchanged.

Behavioral delta: pure-mention-wired channels (no pattern, no
accumulate) will now see every plain group message reach the router
before being dropped there, where before the bridge dropped at the
transport boundary. Extra DB lookup per dropped message in this
specific case; acceptable for the cleaner seam and can be optimized
back at the bridge if it ever matters in practice.

Bridge tests prune the 10 engage_mode-specific cases that covered
logic now owned by evaluateEngage in the router (host-core.test.ts
covers it end-to-end through routeInbound). Bridge tests keep only
what's bridge-specific: the flood gate and the stickySubscribe
coarse union.

172 tests pass (was 182 — net -10 redundant bridge tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 13:32:08 +03:00
gavrielc
f74df3b0d3 fix(router): trust SDK isMention signal; drop broken hasMention regex
The router's mention / mention-sticky engage check was regex-matching
@<agent_group.name> (e.g. @Andy) against message text. Platforms don't
work that way — users address bots via the bot's platform username
(@nanoclaw_v2_refactr_1_bot on Telegram, user-id mentions on Slack /
Discord). The regex matched only coincidentally and never on Telegram,
so mention-mode wirings silently never fired there.

Two parallel mention detectors existed: the Chat SDK's onNewMention,
which correctly resolves the bot's platform identity, and the router's
hasMention text regex, which ignored the SDK verdict and invented its
own heuristic. The router's detector was wrong in principle — the agent
group's display name is a NanoClaw-side nickname, not a platform
address.

Thread the SDK signal through: InboundMessage gains an optional
`isMention` field, the bridge sets it from each handler (onNewMention →
true, onDirectMessage → true, onSubscribedMessage → message.isMention,
onNewMessage(/./) → false), src/index.ts forwards it into InboundEvent,
and evaluateEngage now checks `isMention === true` for mention modes.
hasMention deleted entirely — there is only one source of truth for
"did the user mention this bot": the platform / SDK.

Agent-name-in-text matching for disambiguating multiple agents wired to
one chat is a separate feature; users can express it today with
engage_mode='pattern' + the agent's name as the regex.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 12:16:20 +03:00
gavrielc
c38e5b11a8 fix(channels): wire accumulate mode through the bridge
The router + session DB were already fully plumbed for
ignored_message_policy='accumulate' — fan-out in routeInbound calls
deliverToAgent(wake=false) for non-engaging agents on accumulate wirings,
writeSessionMessage writes trigger=0, countDueMessages filters trigger=1,
container formatter includes all messages regardless of trigger. But the
Chat SDK bridge dropped non-engaging messages before the router ever saw
them, so accumulate was dead on arrival for every adapter that goes
through the bridge.

Expose ignored_message_policy on ConversationConfig, project it in
buildConversationConfigs, and widen shouldEngage's "forward" decision to
"engage OR accumulate" with the union taken across all wirings on a
conversation. stickySubscribe stays gated on a real engage — subscribing
a thread we'd only silently accumulate on would misrepresent the bot's
presence.

shouldEngage return shape is now { forward, stickySubscribe } — engage
was an internal concept the caller never needed, and conflating it with
forward was the source of this bug.

7 new tests cover: non-engaging messages forwarding under accumulate,
mixed drop/accumulate wirings taking the union, accumulate not
triggering sticky subscribe, unknown-conversation drop precedence over
accumulate, and drop policy preserving existing behavior on engaging
messages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 11:18:43 +03:00
gavrielc
52c6223292 fix(channels): register onNewMessage(/./) to fix pattern mode in group chats
Chat SDK dispatch (per handling-events.mdx) is exclusive and prioritized:
subscribed → onSubscribedMessage; unsubscribed + mention → onNewMention;
unsubscribed + pattern match → onNewMessage. We never registered the third,
so engage_mode='pattern' silently dropped every message in unsubscribed
group threads — the SDK simply never surfaced them anywhere.

Register chat.onNewMessage(/./, …) and route it through shouldEngage with
a new 'new-message' source. Unknown-conversation policy drops for this
source (would otherwise flood from every unwired channel the bot can see).
mention / mention-sticky wirings ignore 'new-message' — they require an
explicit @mention to start a conversation. Pattern wirings evaluate
normally.

Extracted shouldEngage from a closure to an exported function with an
EngageSource type so it's unit-testable. Added 17 tests covering every
source × engage-mode combination, unknown-conversation behavior, invalid
regex fail-open, and multi-wiring union.

Accumulate (ignored_message_policy='accumulate') is still not plumbed —
the bridge drops non-engaging messages entirely instead of forwarding
them as context-only. That requires a trigger: 0 | 1 field on
InboundMessage → router → writeSessionMessage (schema already has the
column). Separate change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 11:11:56 +03:00
gavrielc
57e0cda9e5 Revert "fix(channels): pre-subscribe group threads for pattern / accumulate wirings"
This reverts commit 73b20880ff.
2026-04-20 10:35:33 +03:00
gavrielc
73b20880ff fix(channels): pre-subscribe group threads for pattern / accumulate wirings
The engage modes shipped in #1869 included `pattern` (regex match any
message) and the `accumulate` ignored-message policy, but neither could
fire in group chats because Chat SDK only surfaces:

  - DMs (onDirectMessage)
  - @mentions in unsubscribed threads (onNewMention)
  - every message in subscribed threads (onSubscribedMessage)

A bot sitting in a Discord/Slack channel hears *nothing* from a plain
message unless the thread is already subscribed. So `pattern '.'` on a
group wiring → silent. `pattern /urgent/i` → silent. `mention +
accumulate` → the non-mention messages that should be stored as context
were never received, so nothing to accumulate.

Fix: call `chat.subscribe(platformId)` at setup time for every wiring
whose `engageMode === 'pattern'` or `ignoredMessagePolicy === 'accumulate'`.
Failures logged + swallowed per-conversation so one un-subscribable
channel doesn't crash startup.

## Knock-on: SDK stops firing onNewMention once subscribed

Per SDK types:1468, `onNewMention` only fires in unsubscribed threads.
Once we pre-subscribe a channel for a pattern wiring, subsequent mentions
arrive as `onSubscribedMessage` with `message.isMention === true`.

Before: a `mention` wiring coexisting with a `pattern` wiring in the
same channel would silently stop firing after pre-subscribe.

After: `shouldEngage` accepts the `isMention` flag independently from
`source`, so the `mention` mode matches on (dm OR mention-new OR
subscribed-with-isMention). Source shape changed
`'subscribed' | 'mention' | 'dm'` → `'subscribed' | 'mention-new' | 'dm'`
to make the "unsubscribed-mention event" distinction explicit.

## New fields

- `ConversationConfig.ignoredMessagePolicy` — projected from the
  messaging_group_agents row so the bridge knows which wirings need
  pre-subscription. buildConversationConfigs in src/index.ts populates
  it.

Tests: host 153/153, container 46/46. No new tests yet — the subscribe
call path needs a Chat mock, deferred.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 10:34:15 +03:00
gavrielc
9882c94530 fix(channels): use Chat SDK ChatMessage.text, not .content
The engage-mode gating added in #1869 read `message.content` from the
Chat SDK's ChatMessage in all three inbound handlers (onSubscribedMessage,
onNewMention, onDirectMessage). ChatMessage exposes the user-visible
string as `.text` — `.content` exists on the underlying nested structure
but isn't the plain-text field. Result: `shouldEngage` always saw an
empty string, pattern gating never matched, non-wildcard regex wirings
silently dropped every inbound.

Fix: use `message.text` in all three gates.

Discovered during live smoke-test on v2 post-merge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 10:08:35 +03:00
gavrielc
16b9499532 feat(routing): engage modes + sender scope + accumulate/drop + per-agent fan-out
Replaces the opaque trigger_rules JSON + response_scope enum on
messaging_group_agents with four explicit orthogonal columns:

    engage_mode            'pattern' | 'mention' | 'mention-sticky'
    engage_pattern         regex source; required when mode='pattern';
                           '.' is the "always" sentinel
    sender_scope           'all' | 'known'
    ignored_message_policy 'drop' | 'accumulate'

Inbound routing becomes a fan-out — every wired agent is evaluated
independently. A match gets its own session + container wake. A miss
with accumulate keeps the message as context-only (trigger=0) in that
agent's session, so when the agent does eventually engage it sees the
prior chatter.

## Schema

- Migration 010 (`engage-modes`): adds the 4 new columns, backfills
  from trigger_rules.pattern + requiresTrigger + response_scope, drops
  the legacy columns.
- messages_in gains `trigger INTEGER NOT NULL DEFAULT 1` (session DB
  schema + `migrateMessagesInTable` forward-compat).
- countDueMessages gates waking on `trigger = 1`.

## Routing

- `pickAgent` (returns one) → loop over all wired agents. Per agent:
  evaluate engage_mode; run access gate + sender-scope gate; on full
  match → resolveSession + writeSessionMessage(trigger=1) + wake. On
  miss with accumulate → writeSessionMessage(trigger=0), no wake. On
  miss with drop → skip.
- New `findSessionForAgent(agentGroupId, mgId, threadId)` scopes
  session lookup by agent so fan-out doesn't cross sessions.
- `messageIdForAgent` namespaces inbound message ids by agent_group_id
  so PRIMARY KEY doesn't collide across per-agent session DBs.

## Adapter layer

- `ConversationConfig` replaces `triggerPattern` + `requiresTrigger`
  with `engageMode` + `engagePattern`.
- Chat SDK bridge stores `Map<platformId, ConversationConfig[]>` (multi-
  agent per conversation) and applies union gating pre-onInbound:
    * onSubscribedMessage: engage if any wiring keeps firing in
      subscribed state (mention-sticky or pattern)
    * onNewMention: engage on mention; only subscribes the thread if
      at least one wiring is `mention-sticky`
    * onDirectMessage: engage per mode; sticky follows same rule
- Bridge no longer unconditionally calls `thread.subscribe()`.

## Sender scope

- Permissions module registers a second hook `setSenderScopeGate` that
  runs per-wiring after the existing access gate. `sender_scope='known'`
  requires canAccessAgentGroup(); `'all'` is a no-op. Not installed →
  no-op everywhere (default allow).

## Container side

- Host passes `NANOCLAW_MAX_MESSAGES_PER_PROMPT` (reuses existing
  MAX_MESSAGES_PER_PROMPT config; was dead code from v1).
- `getPendingMessages` queries `ORDER BY seq DESC LIMIT N`, reverses to
  chronological order for the prompt — accumulated context rides along
  with trigger rows up to the cap.
- `MessageInRow` gains `trigger: number` so the container can tell them
  apart in downstream code (container still processes both; only the
  host uses `trigger=0` for don't-wake).

## Defaults (per ACTION-ITEMS item 1 decision)

- DM (is_group=0): `engage_mode='pattern'`, `engage_pattern='.'` (always)
- Threaded group: `engage_mode='mention-sticky'` (seed-discord)
- Non-threaded group / CLI: pattern '.' in bootstrap scripts

## Tests

- src/host-core.test.ts: 3 new cases — fan-out (2 agents, 2 sessions,
  2 wakes), accumulate (trigger=0 + no wake), drop (no session created).
- Existing 10 host-core tests still pass.
- Migration 010 runs on an empty DB in 0-row path — verified.

Closes: ACTION-ITEMS items 1, 4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 01:30:04 +03:00
gavrielc
47950671fa docs: add v1→v2 action-items analysis + SDK signal probe tool
- docs/v1-vs-v2/: full v1→v2 regression analysis (SUMMARY + 21 per-module
  docs + ACTION-ITEMS rollup with decisions + timezone recreation spec).
- container/agent-runner/scripts/sdk-signal-probe.ts: empirical harness
  used to characterise Claude Agent SDK event/hook/stderr timing for the
  stuck-detection design in item 9.
- src/channels/chat-sdk-bridge.ts: document the conversations Map staleness
  in a code comment; fix deferred to when dynamic group registration lands
  (ACTION-ITEMS item 17).

No runtime behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 01:00:04 +03:00
gavrielc
27c52205f9 fix(channels): bridge openDM delegates to adapter directly
chat.openDM dispatches via inferAdapterFromUserId, which only recognizes
Discord/Slack/Teams/gChat formats and throws for everything else —
breaking approval delivery on Telegram (numeric IDs) and the other
direct-addressable channels the bridge now wraps. Delegate straight to
adapter.openDM + channelIdFromThreadId, and only expose openDM when the
underlying adapter implements it. Preserves the adapter's native
platform_id encoding (e.g. "telegram:<chatId>") so user_dms caches align
with the messaging_groups rows onInbound wrote.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 18:30:38 +03:00
gavrielc
cc784ff94b refactor(v2): remove trigger_credential_collection MCP tool
Drops the in-chat credential-collection flow introduced in e92b245. Agents
can no longer collect API keys via a secure modal — users must add secrets
through OneCLI directly. Keeps the OneCLI manual-approval handler and
threaded-routing work from the same commit intact.

Removed:
* container/agent-runner/src/mcp-tools/credentials.ts (MCP tool)
* src/credentials.ts (host-side modal/OneCLI pipeline)
* src/db/credentials.ts + migration 005 (pending_credentials table)
* src/onecli-secrets.ts (createSecret CLI facade, only caller was credentials.ts)
* findCredentialResponse from agent-runner DB layer
* PendingCredential types
* Four credential hooks from ChannelSetup (getCredentialForModal,
  onCredentialReject, onCredentialSubmit, onCredentialChannelUnsupported)
* Credential card/modal handling in chat-sdk-bridge (nccr/nccm prefixes,
  Modal/TextInput imports)
* credential_request text fallback in WhatsApp adapter
* request_credential system-action case in delivery.ts

Added:
* Migration 009 drops pending_credentials on existing installs.

Vercel skill now tells the agent to ask the user to register the token via
OneCLI instead of invoking the removed tool.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 21:41:41 +03:00
gavrielc
e55ed0f4e8 fix(whatsapp): upgrade Baileys 6.7→6.17, fix proto import and 515 restart
Baileys 6.7.21 silently failed the pairing handshake. Upgrade to 6.17.16
which fixes this. Three related issues:

1. proto is no longer a named ESM export in 6.17.x — use createRequire
   to import via CJS (matching the proven v1 pattern).
2. Setup auth script didn't handle the 515 stream restart that WhatsApp
   sends after successful pairing. Refactored to reconnect (matching v1's
   connectSocket(isReconnect) pattern) instead of hanging until timeout.
3. Added succeeded guard and process.exit(0) to prevent timeout race
   after successful auth.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:01:55 +03:00
Koshkoshinsk
fdece8047e fix: reply in the Slack DM thread the user wrote in
- chat-sdk-bridge: forward thread.id to the router for DMs so sub-thread
  context survives into delivery. Previously hardcoded to null, which
  collapsed every reply to the DM top level.
- router: when a DM (is_group=0) is wired as `shared`, don't auto-escalate
  to per-thread — keep one session for the whole DM and let thread_id
  flow through to the adapter.
- agent-runner poll-loop: defer follow-up messages whose thread_id
  differs from the active turn's routing. Mixing threads into one
  streaming turn sent every reply to the first thread because routing
  is captured at turn start.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 11:14:05 +00:00
gavrielc
39d2af9981 feat(v2): track unregistered senders + setup improvements
- Add unregistered_senders table to capture dropped message origins
  (one row per sender, upserted with message_count and last_seen)
- Add inbound DM logging to chat-sdk-bridge for debugging
- Add vercel CLI to base container image
- Install vercel-cli and frontend-engineer container skills
- Default requiresTrigger to false in register step

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 12:58:40 +03:00
exe.dev user
8ef26d323f fix(v2/telegram): await pairing interceptor work to serialize DB commits
The Telegram pairing interceptor fired DB writes (createMessagingGroup,
upsertUser, grantRole) and the pairing-success confirmation inside an
unawaited `void (async () => {...})()`. Recent changes (0d3326a user
privilege model, c483860 pairing confirmation) widened the work done
inside this closure to include an extra two DB writes and a Telegram
API round-trip, making the race between match and commit reproducible
— a paired message could appear "lost" until a second send.

Change onInbound to optionally return a Promise, await it in the
chat-sdk-bridge dispatch callbacks, and make the pairing interceptor
async so its DB writes + confirmation send complete before the handler
resolves.

Note: the upstream @chat-adapter/telegram SDK itself does not await
processUpdate in its polling loop, so the adapter's getUpdates offset
still advances before our handler resolves. A true restart-safe fix
needs a corresponding change in chat-adapter.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 12:16:49 +00:00
Koshkoshinsk
c483860cd9 feat(v2/telegram): send pairing-success confirmation to paired chat
After a Telegram pair-code is successfully consumed, send a one-shot
"Pairing success! I'm spinning up the agent now, you'll get a message
from them shortly." reply to the same chat so the user knows the code
was accepted before the agent's own welcome DM arrives.

Best-effort: any sendMessage failure is logged but not rethrown, so a
Telegram outage can't undo a successful pairing or trigger the
interceptor's fail-open path.

Also includes a no-op prettier reformat in chat-sdk-bridge.ts that the
husky hook missed in the previous commit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 07:59:17 +00:00
Koshkoshinsk
ed5dc5ea51 fix(v2/chat-sdk): project author into flat sender fields for router gate
The chat-sdk bridge was emitting inbound messages with a nested
author.{userId,fullName,userName} shape, but router.ts:extractAndUpsertUser
reads flat content.senderId / sender / senderName. Result: every chat-sdk
adapter (telegram, discord, slack, teams, gchat, webex, matrix, resend,
imessage, whatsapp-cloud) hit the strict access gate with userId=null and
got dropped, even for the registered owner.

Project author into the flat fields inside messageToInbound so the bridge
matches the contract documented at router.ts:14-17. Native adapters
already set these directly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 07:38:02 +00:00
gavrielc
0d3326aae5 feat(v2): user-level privilege model + cold DM infra + init-first-agent skill
Replaces the agent-group-centric "main group" concept with user-level
privileges and adds the cold-DM infrastructure needed for proactive
outbound messaging (pairing, approvals, welcome flows).

Privilege model
- New tables: users, user_roles (owner global-only; admin global or
  scoped to an agent_group), agent_group_members (explicit non-
  privileged access; admin/owner imply membership), user_dms (cold-DM
  resolution cache).
- Removed agent_groups.is_admin, messaging_groups.admin_user_id. Replaced
  with messaging_groups.unknown_sender_policy (strict | request_approval
  | public) for per-chat unknown-sender gating.
- src/access.ts: canAccessAgentGroup, pickApprover, pickApprovalDelivery.
- src/router.ts: access gate on every inbound, honoring
  unknown_sender_policy for unknown senders.
- src/channels/telegram.ts: pairing interceptor upserts the paired user
  and promotes them to owner if hasAnyOwner() is false (first-pair-wins).

Cold DM infrastructure
- ChannelAdapter.openDM?(handle) — optional method. Chat-SDK-bridge wires
  it to chat.openDM() for resolution-required channels (Discord, Slack,
  Teams, Webex, gChat); direct-addressable channels (Telegram, WhatsApp,
  iMessage, Matrix, Resend) fall through to the handle directly.
- src/user-dm.ts: ensureUserDm(userId) — resolves + caches via user_dms.

Approval routing
- onecli-approvals + delivery use pickApprover + pickApprovalDelivery:
  scoped admins → global admins → owners (dedup), first reachable via
  ensureUserDm, same-channel-kind tie-break. Approvals land in the
  approver's DM, not the origin chat.

Delivery fixes
- delivery.ts ACL rejection now throws instead of returning undefined —
  the outer loop previously marked rejected messages as delivered.
- Implicit-origin allow: session.messaging_group_id === target skips the
  destination check.
- createMessagingGroupAgent auto-creates the companion agent_destinations
  row (normalized local_name from the messaging group's name, collision-
  broken within the agent's namespace).

Container
- container-runner.ts: /workspace/global always read-only; drops
  NANOCLAW_IS_ADMIN; adds NANOCLAW_ADMIN_USER_IDS (owners + global admins
  + scoped admins for this agent group). Agent-runner poll-loop gates
  slash commands against that set.

New skill: /init-first-agent
- Walks the operator through standing up the first agent for a channel:
  channel pick → identity lookup (reads each channel SKILL.md's
  ## Channel Info > how-to-find-id) → DM platform_id resolution (direct-
  addressable, cold-DM via "user DMs bot first + sqlite lookup", or
  Telegram pair-code fallback) → run scripts/init-first-agent.ts →
  verify via tail of nanoclaw.log.
- scripts/init-first-agent.ts: parameterized helper that upserts the
  user + grants owner (if none), creates dm-with-<display-name> agent
  group + initGroupFilesystem, reuses/creates the DM messaging_group,
  wires it (auto-creates destination), resolves the session, and writes
  a kind:'chat' / sender:'system' welcome message into inbound.db. Host
  sweep wakes the container and the agent DMs the operator via the
  normal delivery path.

/manage-channels rewrite
- Drops --is-main / --jid / main-vs-non-main isolation references.
- First-channel flow delegates to /init-first-agent.
- Explains createMessagingGroupAgent auto-creates destinations.
- Adds a privileged-users show section.

setup/
- register.ts: drop --is-main, --jid, --local-name, --trigger
  requiresTrigger defaults; call initGroupFilesystem; normalize to
  v2 schema (no is_admin, no admin_user_id, sets unknown_sender_policy
  'strict'); let createMessagingGroupAgent handle the destination row.
- pair-telegram.ts: emit PAIRED_USER_ID (namespaced "telegram:<id>")
  instead of ADMIN_USER_ID; update header comment.
- register.test.ts deleted — was v1-only, tested a registered_groups
  table that no longer exists.

Docs
- v2-architecture-diagram.{md,html}: ER diagram updated to drop
  is_admin/admin_user_id, add unknown_sender_policy, and include
  users/user_roles/agent_group_members/user_dms.
- v2-architecture-draft.md: approval-routing paragraph rewritten for
  pickApprover/pickApprovalDelivery/ensureUserDm; SQL schema block
  updated; admin-verification paragraph references
  NANOCLAW_ADMIN_USER_IDS.
- v2-setup-wiring.md: entity-model sketch rewritten.
- v2-checklist.md: marked privilege refactor / container filtering /
  approval routing / unknown-sender gating done; removed obsolete
  admin_user_id and main-vs-non-main items.

Scripts
- scripts/init-first-agent.ts (new) replaces scripts/welcome-owner-dm.ts
  (removed; welcome-owner was a Discord-specific one-off).
- test-v2-host.ts, test-v2-channel-e2e.ts, seed-discord.ts: drop
  is_admin + admin_user_id, use unknown_sender_policy.

Tests
- src/access.test.ts (new): 14 tests for canAccessAgentGroup, role
  helpers, pickApprover, ensureUserDm, pickApprovalDelivery.
- src/db/db-v2.test.ts: adds 3 tests for the auto-created
  agent_destinations row (normalized name, no duplicates, collision
  break within an agent group).
- host-core.test.ts, channel-registry.test.ts: updated fixtures to
  use unknown_sender_policy: 'public' where the test exercises routing
  rather than the access gate.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 00:03:51 +03:00
Koshkoshinsk
2df81e0b32 fix(v2/approvals): render correct title + selected label after click
Approval cards bypass the deliverMessage path that populates
pending_questions, so the post-click lookup found nothing and the
card edit fell back to " Question" + the raw option value
("approve"/"reject"). Store title and normalized options on
pending_approvals as well, and look up either table via a shared
getAskQuestionRender helper so the chat-sdk post-click edit and the
Discord interaction callback render the per-card title and the
selectedLabel (e.g. " Approved").

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 15:31:44 +00:00
Koshkoshinsk
42467d796d style: apply prettier formatting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 15:31:44 +00:00
Koshkoshinsk
d92d75e173 feat(v2/approvals): per-card titles and structured options
Approval cards now carry a required title (Add MCP Request, Install
Packages Request, Rebuild Request, Credentials Request) and structured
options with distinct pre-click label, post-click selectedLabel (e.g.
" Approved" / " Rejected"), and value used for click routing. The
title and normalized options are persisted in pending_questions so the
post-click card edit can render the correct per-type title and selected
label on both chat-sdk channels and Discord interactions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 15:31:44 +00:00
Koshkoshinsk
f304c67318 fix(telegram): sanitize outbound markdown for legacy parse mode
The @chat-adapter/telegram adapter hardcodes parse_mode=Markdown (legacy)
but its converter emits CommonMark. Messages containing **bold** or list
bullets that round-trip to `*` produce "can't parse entities" errors and
get dropped after retries.

Add an opt-in transformOutboundText hook on the chat-sdk bridge and wire
a Telegram-specific sanitizer that downgrades **bold** to *bold*, rewrites
dash/plus list bullets to a Unicode bullet so the adapter's re-stringify
doesn't inject stray `*`, and strips unbalanced delimiters or brackets.
Only Telegram opts in; other channels are unaffected.

Workaround until upstream (vercel/chat) ships mode-aware conversion —
PR #367 adds a parseMode knob but not the converter fix.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 10:30:32 +00:00
gavrielc
f0e4f07ac2 refactor(v2): extract webhook server into standalone module
Aligns with upstream feat/chat-sdk-integration pattern: regex-based
routing (/webhook/{adapterName}), response streaming, cleanup function.
Updates Slack and Teams skill docs to match /webhook/{name} convention
used by all other v2 channel skills.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 17:36:16 +03:00
gavrielc
5a606a83d4 refactor(v2): use Chat SDK webhooks proxy and clean up webhook server
Route webhook requests through chat.webhooks[name]() instead of calling
adapter.handleWebhook() directly, getting proper auto-initialization and
signature verification. Extract Node↔Web Request/Response conversion
into reusable helpers, parse URL pathname properly for query string
safety, and support all HTTP methods (not just POST).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 17:36:16 +03:00
gavrielc
669a8444ef refactor(v2): extract session DB operations into src/db/session-db.ts
Move all raw SQL out of session-manager, delivery, and host-sweep into
a dedicated DB module. Make session schemas idempotent (IF NOT EXISTS)
so initSessionFolder always applies them. Revert the markdown
plain-text retry from 4c477ac.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 17:36:16 +03:00
Gabi Simons
9476a80ab0 feat(v2): shared webhook server for webhook-based channel adapters
Adds a shared HTTP server (port 3000, configurable via WEBHOOK_PORT)
that routes incoming webhooks to the correct Chat SDK adapter by path
(e.g. /api/webhooks/slack, /api/webhooks/teams). Required by Slack,
Teams, GitHub, Linear, and other non-gateway adapters.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 12:33:45 +00:00
Koshkoshinsk
4c477acca3 fix(v2): retry as plain text when adapter rejects markdown
A single message with markdown the adapter couldn't parse (e.g. Telegram
MarkdownV2 entity errors) would fail in deliverSessionMessages and be
retried forever, blocking every subsequent reply on that session.

Catch ValidationError from postMessage and retry once with the markdown
stripped to plain text via markdownToPlainText. Files re-attach in a
follow-up post since the plain-text retry drops the files payload shape.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 09:32:12 +00:00
gavrielc
9dda75bb21 docs(v2): cross-mount invariants + diagrams; inline a2a routing
- session-manager.ts: shrink the cross-mount invariant header from 31
  lines to 12, keeping each invariant's cause and consequence inline.
- agent-runner/db/connection.ts: parallel cross-mount comment for the
  container-side reader (inbound.db must be journal_mode=DELETE).
- agent-runner/db/messages-out.ts: document that even/odd seq parity
  is load-bearing — seq is the agent-facing message ID returned by
  send_message and consumed by edit_message / add_reaction, looked
  up across both tables.
- v2-checklist.md: record the cross-mount invariants and seq parity
  under Core Architecture so future "simplifications" don't regress
  them.
- scripts/sanity-live-poll.ts: empirical validation harness for the
  three cross-mount invariants — flips each one and observes silent
  message loss / corruption.
- delivery.ts: inline routeAgentMessage at its single callsite (-17
  net lines). The wrapper added more boilerplate than it factored.
- docs/v2-architecture-diagram.{md,html}: rendered Mermaid diagrams
  of the v2 system, message flow, named destinations, entity model,
  and the two-DB split.
- channels/adapter.ts, chat-sdk-bridge.ts, credentials.ts,
  db/sessions.ts, db/db-v2.test.ts: prettier format pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 00:21:12 +03:00
gavrielc
e92b245399 feat(v2): OneCLI 0.3.1 — approvals, credential collection, threaded routing
Three features built on top of @onecli-sh/sdk 0.3.1, landed together because
they share wiring surfaces (session DB schema, delivery dispatcher, Chat SDK
bridge, channel adapter contract).

## OneCLI manual-approval handler

* `src/onecli-approvals.ts` — long-polls OneCLI via the SDK's
  `configureManualApproval`; on each request, delivers an `ask_question` card
  to the admin agent group's first messaging group, persists a
  `pending_approvals` row, and waits on an in-memory Promise resolved by the
  admin's button click or an expiry timer. Expired cards are edited to
  "Expired (...)" and a startup sweep flushes any rows left over from a
  previous process.
* Short 11-byte approval id (`oa-<8 base36>`) instead of the SDK's UUID so the
  Telegram 64-byte `callback_data` limit is respected; the OneCLI UUID stays
  in the persisted payload for audit.
* Migration 003 consolidated: `pending_approvals` now has the OneCLI-aware
  columns from the start (`agent_group_id`, `channel_type`, `platform_id`,
  `platform_message_id`, `expires_at`, `status`), `session_id` relaxed to
  nullable so cross-session approvals fit.
* `handleQuestionResponse` in `src/index.ts` now routes OneCLI approvals
  through `resolveOneCLIApproval` before falling back to the
  session-bound approval path.

## Credential collection from chat

New `trigger_credential_collection` MCP tool — the agent researches a
third-party API, calls the tool with `{name, hostPattern, headerName,
valueFormat, description}`, and blocks until the host reports saved, rejected,
or failed. The credential value never enters the agent's context: the user
submits it into a Chat SDK Modal on the host side, the host writes it to
OneCLI via a thin facade (`src/onecli-secrets.ts` — shells out to
`onecli secrets create`, shape mirrors the SDK we expect upstream), and only
the status string flows back to the container via a system message.

* `src/credentials.ts` — host-side handler: delivers the card to the
  conversation's own channel (not the admin channel — credential collection
  is a user-facing flow, distinct from admin approval), persists a
  `pending_credentials` row, drives the submit → `createSecret` → notify
  pipeline. Falls back gracefully when the channel doesn't support modals.
* `src/db/credentials.ts` + migration 005: `pending_credentials` table.
* `src/channels/chat-sdk-bridge.ts`: renders a `credential_request` card,
  handles the `nccr:` action prefix by opening a Modal with a TextInput,
  registers an `onModalSubmit` handler for the `nccm:` callback prefix.
* `container/agent-runner/src/mcp-tools/credentials.ts`: the blocking MCP
  tool, mirroring the `ask_user_question` polling pattern.
* `container/agent-runner/src/db/messages-in.ts`: `findCredentialResponse`
  helper to pick up the system message the host writes back.

## Threaded adapter routing

The destination layer previously didn't carry thread context, so agent replies
to Discord always landed in the root channel regardless of which thread the
inbound came from.

* `ChannelAdapter.supportsThreads: boolean` — declared by every channel skill
  at `createChatSdkBridge`. Threaded: Discord, Slack, Teams, Google Chat,
  Linear, GitHub, Webex. Non-threaded: Telegram, WhatsApp Cloud, Matrix,
  Resend, iMessage.
* `src/router.ts`: non-threaded adapters strip `threadId` at ingest (threads
  collapse to channel-level sessions). Threaded adapters override the
  wiring's `session_mode` to `'per-thread'` so each thread = a session
  (except `agent-shared`, which is preserved as a cross-channel intent the
  adapter can't know about).
* `session_routing` table in `inbound.db` — single-row default reply routing
  written by the host on every container wake from
  `session.messaging_group_id` + `session.thread_id`. Forward-compat
  `CREATE TABLE IF NOT EXISTS` handles older session DBs lazily.
* `container/agent-runner/src/db/session-routing.ts` — container-side reader.
* `send_message` / `send_file` / `ask_user_question` / `send_card` /
  scheduling tools all default their routing (channel, platform, **and**
  thread) from the session when no explicit `to` is given. Explicit `to`
  uses the destination's channel with `thread_id = null` (cross-destination
  sends start a new conversation elsewhere).
* `poll-loop.ts::sendToDestination` (the final-text single-destination
  shortcut) now inherits `thread_id` from `RoutingContext` too — this was
  the root cause of Discord replies landing in the root channel even after
  `send_message` was wired correctly.

## Related cleanups

* `src/container-runner.ts`: OneCLI agent identifier switched from the lossy
  folder-derived string to `agent_group.id`, making `getAgentGroup(externalId)`
  a trivial reverse lookup for per-agent scoping.
* `wakeContainer` race fix via an in-flight promise map — concurrent wakes
  during the async buildContainerArgs / OneCLI `applyContainerConfig` window
  no longer double-spawn containers against the same session directory.
* `src/db/db-v2.test.ts`: dropped the brittle `expect(row.v).toBe(N)` schema
  version assertion — it had to be bumped on every migration addition.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 17:18:21 +03:00
gavrielc
d8fbd3b239 feat: agent-to-agent communication, dynamic agent creation, self-modification tools
Agent-to-agent: host routes messages with channel_type='agent' to target
agent's inbound.db, enriches with sender info, wakes target container.
Bidirectional routing works via inherited routing context.

Dynamic agents: create_agent MCP tool + system action handler creates
agent groups, folders, and optional CLAUDE.md on the fly.

Self-modification: install_packages (apt/npm, requires admin approval),
add_mcp_server (no approval), request_rebuild (builds per-agent-group
Docker image with approved packages). Approval flow reuses interactive
card infrastructure with pending_approvals table.

Also includes fixes from prior session: attachment download, reply context
extraction, message editing (platform message ID tracking), delivery retry
limits, and card update on button click.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 01:11:06 +03:00
gavrielc
d656b5ccc1 fix: Chat SDK bridge delivery and typing for non-Discord adapters
- Use platformId directly as thread ID in deliver() and setTyping()
  instead of calling encodeThreadId with Discord-shaped args — platformId
  is already in the adapter's encoded format (e.g. "telegram:6037840640")
- Add triggerTyping() in delivery.ts, call from router on message route
- Enable Telegram channel in barrel
- Verified E2E: Telegram message in → agent → typing indicator → response

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 13:36:45 +03:00
gavrielc
2b64fec0e6 fix: clean up iMessage adapter type compatibility
Replace `as never` cast with proper polyfill for channelIdFromThreadId.
Narrow GatewayAdapter cast to only the gateway code path in bridge.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:42:49 +03:00
gavrielc
8a06b01646 v2: SQLite state adapter, admin commands, compact feedback
- Replace in-memory Chat SDK state with SqliteStateAdapter — thread
  subscriptions now persist across restarts
- Add migration 002 for chat_sdk_kv, subscriptions, locks, lists tables
- Handle /clear in agent-runner (reset sessionId) — SDK has
  supportsNonInteractive:false for this command
- Pass /compact, /context, /cost, /files through to SDK as admin commands
- Skip admin commands in follow-up poll so they start fresh queries
- Emit compact_boundary events as user-visible feedback messages
- Pass NANOCLAW_ADMIN_USER_ID and NANOCLAW_ASSISTANT_NAME to containers

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 03:58:35 +03:00
gavrielc
c31bb02c06 v2 phase 5: pending questions with interactive cards
End-to-end ask_user_question flow:
- Agent MCP tool writes question card to messages_out
- Host delivery creates pending_questions row, delivers as Discord Card with buttons
- Local webhook server receives Gateway INTERACTION_CREATE events
- Acknowledges interaction + updates card to show selected answer
- Routes response back to session DB as system message
- MCP tool poll picks up response and returns to agent

Key fixes:
- Poll loop now skips system messages (reserved for MCP tool responses)
- Gateway listener uses webhookUrl forwarding mode for interaction support
- Button custom_id encodes questionId + option text for self-contained routing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 03:26:16 +03:00
gavrielc
c348fabf22 v2 phase 5: scheduling fixes, media handling, command processing
- Host sweep: fix DELETE journal mode, busy_timeout, seq in recurrence INSERT
- Outbound files: delivery reads from outbox dir, passes buffers to adapter,
  cleans up after delivery. Chat SDK bridge sends files via postMessage.
- Inbound attachments: formatter includes attachment info in prompts
- Commands: categorize /commands as admin, filtered, or passthrough.
  Admin commands check sender against NANOCLAW_ADMIN_USER_ID.
  Filtered commands silently dropped. Passthrough sent raw to agent.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 02:59:33 +03:00
gavrielc
afbc20a6c4 v2 phase 4+5: Discord via Chat SDK, expanded MCP tools, message seq IDs
- Chat SDK bridge + Discord adapter (gateway listener, message routing)
- MCP tools refactored into modular structure: core (send_message, send_file,
  edit_message, add_reaction), scheduling (schedule/list/cancel/pause/resume
  tasks), interactive (ask_user_question, send_card), agents (send_to_agent)
- Message seq IDs: shared integer sequence across messages_in/out so agents
  see small numeric IDs instead of platform snowflakes
- busy_timeout=5000 for session DB (poll loop + MCP server concurrent access)
- Always copy agent-runner source to fix stale cache when non-index files change
- Seed script for Discord testing, e2e test script

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 02:53:39 +03:00