Skip to content

feat(connections): Signal channel via signal-cli (device-link pairing + send-only)#12

Merged
ogarciarevett merged 2 commits into
mainfrom
feat/signal-channel
Jun 5, 2026
Merged

feat(connections): Signal channel via signal-cli (device-link pairing + send-only)#12
ogarciarevett merged 2 commits into
mainfrom
feat/signal-channel

Conversation

@ogarciarevett

Copy link
Copy Markdown
Owner

Why

Signal was the one channel from the connections umbrella still unbuilt — a catalog entry + tutorial only. It is the most privacy-aligned channel and the natural target for the proactive ctx.notify shipped in #11: a pipeline result lands in the user's own Signal. Unlike Telegram/Discord (hosted bot APIs) and WhatsApp-Web (a bundled library), Signal has no hosted API and no npm SDK — it is reached through the local signal-cli binary, exactly the bring-your-own-binary model Vesper already uses for the LLM CLIs. So Signal is a core handler with zero new dependencies.

This is the disciplined first increment — device-link pairing + send-only — mirroring how WhatsApp shipped (no-op receive). Inbound receive is deferred.

What changed

  • SignalHandler (connections/signal.ts, ChannelHandler + Pairable): send via per-call signal-cli send (asserts NETWORK_FETCH; subprocess egress, so the host-allowlist is N/A for this local-cli transport); receive is a no-op Stoppable (send-only v1); startPairing is self-driving device-link QR.
  • SignalCli seam (connections/signal-cli.ts): probe/send ride the batch ProcessRunner; link rides a streaming Bun.spawn (the sgnl://linkdevice URI prints while the process blocks awaiting a scan). Parsing (parseSignalLinkLine) and line handling (streamLines/mergeStreamLines/linkEventsFromLines) are pure and unit-tested — the suite spawns nothing.
  • Pairing persists the linked account number to the vault (signal_account) and emits it as the chatId, so the coordinator records it as params.defaultChatId → a paired Signal is a ctx.notify target (Note to Self). Reuses the whatsapp-web self-driving coordinator branch (pairingNeedsInbound:false) unchanged.
  • Plugin entry + catalog status: ready; ConnectionErrorReason gains not_installed (honest "install signal-cli" reason).
  • signal-cli owns the session keys (its own encrypted data dir); the vault holds only the account number — a documented deviation from "every credential in the vault".

No new npm dependency, no migration, no Capability-union change, no PairingCoordinator change.

Test plan

  • bun test — 916 pass / 0 fail (+26).
  • Coverage: signal.ts 100%, signal-cli.ts 86% (uncovered = the Bun.spawn glue, like runProcess).
  • bun run lint (Biome) — clean.
  • tsc --noEmit — 0 new errors.
  • Process seam mocked end to end — no real signal-cli runs in the suite.
  • Not exercised against a live signal-cli (none in CI). The exact probe subcommand and the Associated with: line format are signal-cli-version-dependent — the main unverified risk; isolated behind the seam.

Issue-capped workspace: the record is specs/signal-channel.md (local) + the cycle-log.md entry + this PR (Rule 11 fallback).

Follow-ups

  • Inbound receive → chatbot (needs the long-lived signal-cli daemon --http JSON-RPC transport; egress would then ride allowlistedFetch to 127.0.0.1).
  • Group messaging / attachments; verifying probe + link line formats against a real signal-cli build.

…elivery

Pipeline-facing seam so a running pipeline can push a notification to the user
out a connected channel — the outbound complement to the inbound chatbot flow.

- PipelineContext.notify(text, opts?) gated by NETWORK_FETCH; NotifyFn injected
  via BuildContextDeps + SchedulerOptions where `complete` is threaded
  (top-level + sub-agent). Core stays decoupled (channel: string, not ChannelId).
- Graceful: missing channel/destination/resolver -> {delivered:false,reason};
  only a capability violation throws.
- Host makeNotifyFn (CLI) resolves channel + pairing-persisted owner chatId,
  sends through the daemon's running handler, audits each send on events
  (notification_sent/notification_failed; body + chat id never logged).
- config.notify.defaultChannel normalization; no migration, no new capability,
  no new dependency.

890 tests / 0 fail (+20); 100% line+func coverage on new units; Biome clean.
… + send-only)

Completes the channel set with Signal, the last deferred channel. signal-cli is
an external binary (no SDK), so Signal is a core handler on the existing
ProcessRunner seam — zero new dependencies.

- SignalHandler (ChannelHandler + Pairable): send via per-call `signal-cli send`
  (NETWORK_FETCH asserted; subprocess egress, allowlist N/A for local-cli),
  no-op receive (send-only v1), self-driving device-link QR pairing.
- SignalCli seam: probe/send over the batch ProcessRunner; link over a streaming
  Bun.spawn (URI prints before exit). Parsing + line-merge pure + unit-tested.
- Pairing persists the linked number to the vault + emits it as chatId, so a
  paired Signal is a ctx.notify target. Reuses the whatsapp-web self-driving
  coordinator branch unchanged.
- Plugin + catalog ready; ConnectionErrorReason gains not_installed.
- No new dependency, no migration, no Capability-union change.

916 tests / 0 fail (+26); signal.ts 100% / signal-cli.ts 86% coverage; Biome clean.
@ogarciarevett ogarciarevett merged commit c01db04 into main Jun 5, 2026
1 check 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.

1 participant