Compare commits

..

41 Commits

Author SHA1 Message Date
gavrielc ca19b744b3 fix: exchange archives stamp local time
Archived: lines and archive filename dates in agent-readable conversation
markdown render in the install timezone instead of raw UTC ISO — evening
sessions west of UTC no longer archive under tomorrow's date.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2YZQDTw9TQrH3m8NBtVAW
2026-07-10 21:03:49 +03:00
gavrielc d2dd91b89f Merge pull request #2908 from amit-shafnir/providers-agnostic-surfaces
feat(codex): persona prepend + git-independent skill discovery for template agents
2026-07-05 15:14:30 +03:00
Amit Shafnir 40b6a57527 fix(codex): expose group skills at $HOME/.agents/skills so codex discovers them
Codex only scans the CWD-level `.agents/skills` when the CWD is inside a git
repo. The agent workspace (/workspace/agent) is not a git repo, so template and
shared skills materialized into /workspace/agent/.agents/skills were never
discovered. Codex DOES scan the user-level `$HOME/.agents/skills`
unconditionally (verified against codex-cli 0.141 at a non-git CWD), so mount
the group's `.agents` dir at /home/node/.agents as well. Skill materialization
stays in the provider-neutral group-skills.ts seam; only the exposure is here.

Also correct the AGENTS.md self-authored-skills pointer: `/workspace/agent/skills`
is persistent but not codex-scanned, and the "linked into $CODEX_HOME/skills at
boot" claim was never implemented. Point authored skills at `~/.codex/skills`
(writable via the .codex-shared mount and scanned by codex).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 23:17:53 +03:00
Amit Shafnir ed2e3b4c85 feat(codex): persona prepend + provider-agnostic skills mirror
codex-agents-md.ts inlines the group persona (readGroupPersona from the main seam)
as the first, never-evicted section of AGENTS.md. codex.ts mirrors template skills
into .agents/skills via the shared materializeTemplateSkills helper. Tests added
for both. Local-only commit to enable NANOCLAW_PROVIDERS_REF testing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 20:57:26 +03:00
gavrielc c57076649a Merge pull request #2836 from nanocoai/chore/bump-chat-sdk-4.29.0-providers
chore(deps): bump chat SDK to 4.29.0 (providers)
2026-06-23 15:28:18 +03:00
Gabi Simons d5dc9ef85c chore(deps): bump chat SDK to 4.29.0 (providers branch)
Companion to the core `chat` 4.29.0 bump on main and the @chat-adapter bump on
the channels branch. Pins `chat` and the channel-adapter install references on
the providers branch to the matched 4.29.0 release, so a provider-branch install
that also adds a channel stays on the same Chat SDK generation as main's bridge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 12:37:23 +03:00
omri-maya 842f6ba565 Merge pull request #2772 from nanocoai/fix/codex-per-thread-archive
fix(codex): per-thread conversation archive (CDX-004)
2026-06-15 17:00:50 +03:00
Omri Maya 780265225f chore(codex): trim filename comment to the non-obvious bit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:58:35 +03:00
Omri Maya 9fa85ccf95 chore(codex): drop ponytail comment
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:56:03 +03:00
Omri Maya dfd3ee31a9 fix(codex): derive date-prefix strip from regex, drop magic offset
f.slice(11) silently assumed len("YYYY-MM-DD-") and was coupled to the
`dated` regex two lines up — change the date format and it points at the
wrong offset while still "working". Strip with f.replace(dated, '') so the
prefix length lives in exactly one place.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:54:34 +03:00
Koshkoshinsk 47f8296e67 fix(codex): date-prefix the per-thread archive filename, stable across days
Restore a sortable `YYYY-MM-DD-` prefix to the conversation archive filename
(parity with the Claude path) while keeping it thread-stable: reuse the
thread's existing file regardless of date so later exchanges keep appending to
one file, and only stamp the creation date when the file doesn't exist yet.
Exact-suffix match past the date prefix avoids substring collisions between
threads with shared prefixes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 15:10:13 +03:00
Koshkoshinsk dab93fc592 feat(codex): append per-thread conversation archive
The Codex provider wrote one standalone file per exchange because its
app-server keeps conversation history server-side, with no on-disk
transcript to roll up at a compaction boundary. Key the archive file on
the thread/continuation id and append each completed exchange instead, so
a session lands in one growing file — matching the Claude path's
one-file-per-session granularity.

The thread-level header (provider, continuation id) is written once when
the file is created; each appended block carries its own timestamp and
status. Distinct threads still get distinct files.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 15:10:13 +03:00
gavrielc 0ffe5582f0 refactor(codex): install Codex CLI via cli-tools.json manifest, not the Dockerfile
Trunk's adfae67 moved global Node CLI installs into container/cli-tools.json
(a json-merge seam) so a skill adds a CLI without editing the Dockerfile. The
Codex provider still hardcoded its CLI into the Dockerfile and guarded that
shape with a test. Migrate it:

- Drop the codex ARG + RUN from this branch's reference Dockerfile.
- Replace codex-dockerfile.test.ts with codex-cli-tools.test.ts, which asserts
  the @openai/codex entry in cli-tools.json (skips when the manifest is absent,
  e.g. on the bare providers branch).
- setup/providers/codex install-check verifies the manifest entry, not the
  Dockerfile.

Pairs with the trunk-side add-codex.sh / SKILL.md change that appends the
manifest entry instead of awk-ing the Dockerfile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 21:36:16 +03:00
gavrielc 4b5576faea Merge pull request #2757 from nanocoai/providers-codex-v2
feat(codex): Codex agent-provider payload v2 — app-server on capability seams, vault-only auth
2026-06-14 21:29:20 +03:00
Omri Maya 904871aaa7 feat(codex): Codex agent-provider payload v2 — app-server on capability seams, vault-only auth
Rewrite the Codex provider onto the host's capability seams. Codex runs as a
real agent provider via `codex app-server` — planning, MCP tools, server-side
history, session resume — not as an MCP tool under Claude.

- Host provider (src/providers/codex.ts, codex-agents-md.ts): registers on the
  provider-container seam, composes AGENTS.md from the real config row, mounts a
  per-group ~/.codex state dir, vault-only auth stub (no credential in-container).
- Container runtime (codex.ts, codex-app-server.ts): app-server transport, turn
  lifecycle, racing-follow-up fix (clear the active turn on completion).
- Provider-owned per-exchange archiving (exchange-archive.ts) via onExchangeComplete.
- Codex CLI pinned to 0.138.0 in the Dockerfile (ARG + global install), guarded
  by a structural dockerfile test.
- macOS first-spawn fix: pre-create the auth-stub mountpoint.

The /add-codex skill is dropped from this branch — trunk is its canonical home.
The authored-skills canonical store is deferred to a future provider-seam PR
(its stale host-contribution assertion is removed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 07:49:23 +03:00
gavrielc 5e76b9d7e8 test(providers): add barrel-driven registration tests for opencode + codex
Behavior tests in both trees that import ONLY the real barrel (host
src/providers/index.ts → listProviderContainerConfigNames; container
providers/index.ts → listProviderNames) and assert the provider is present.
Unlike the existing *.factory.test.ts (which import the provider module
directly, self-register, and stay green when the barrel line is deleted),
these go red if the barrel reach-in is removed/drifts. The opencode container
test also implicitly guards @opencode-ai/sdk via its unmocked barrel import.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:09:08 +03:00
gavrielc b429ab37b8 Merge pull request #2152 from glifocat/fix/opencode-process-group-and-timeout
fix(opencode): kill server process group + configurable IDLE_TIMEOUT_MS
2026-05-01 18:40:39 +03:00
gavrielc 09ddde33e1 Merge pull request #2153 from glifocat/fix/opencode-instructions-pipeline
fix(opencode): use native instructions config to load CLAUDE.md and fragments
2026-05-01 17:44:26 +03:00
gavrielc c0c46c14d6 fix(opencode): drop obsolete NANOCLAW_IS_MAIN / global CLAUDE.md branch
NANOCLAW_IS_MAIN no longer exists in the v2 codebase, and
groups/global/ is explicitly migrated away by composeGroupClaudeMd
(shared base now lives in container/CLAUDE.md → /app/CLAUDE.md, which
the instructions array already includes). Carrying /workspace/global
would give OpenCode more context than the Claude provider sees.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 17:44:01 +03:00
Ethan c993527e25 fix(opencode): load CLAUDE.md context via native instructions pipeline
Before: wrapPromptWithContext concatenated /workspace/agent/CLAUDE.md
verbatim into a <system>...</system> block in the user-message text.
That file is the host-composed entry point that contains only `@./...`
includes (the .claude-shared.md symlink to /app/CLAUDE.md, plus the
module fragments). OpenCode does not expand `@` in instruction files —
the syntax is a Claude Code convention for the model's own Read tool
to lazy-load. Result: the model received the literal lines
"@./.claude-shared.md\n@./.claude-fragments/module-...md" as text and
saw none of the actual content (workspace, memory, conversation
history, agents/core/interactive/scheduling/self-mod modules, OneCLI,
etc.). Confirmed empirically by dumping the constructed prompt for a
turn before any fix.

After: pass the concrete files via OpenCode's native `instructions`
config field. Per packages/opencode/src/session/instruction.ts on the
upstream `dev` branch, absolute paths and globs are resolved with
`fs.glob(basename, { cwd: dirname, absolute: true, include: 'file' })`,
files are read raw, and the resulting strings are concatenated into
the LLM's system prompt at packages/opencode/src/session/prompt.ts:1442.
That is the canonical channel — same one OpenCode uses for AGENTS.md /
CLAUDE.md auto-discovery.

Configured set:
  /app/CLAUDE.md                              shared base
  /workspace/agent/.claude-fragments/*.md     per-skill fragments
  /workspace/agent/CLAUDE.local.md            per-group memory
  /workspace/global/CLAUDE.md                 cross-group memory (non-main)

Removed: readClaudeMdForPrompt, the manual <system> wrap of its output,
and the now-unused `fs` import. The dynamic systemInstructions wrap
(assistant name + destinations) stays as-is — that varies per call.

Verified post-fix: Citiclaw (gemma4:31b via Ollama) responded to "what
self-modification tools do you have" with the exact MCP tool names and
a structured bullet list — vs. the previous ungrounded "I haven't
received instructions" response.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 12:54:56 +02:00
Ethan 0367dbb6f0 fix(opencode): kill server process group + configurable idle timeout
Two bugs in the upstream OpenCode provider that fire together when a
local backend (Ollama, llama.cpp) is slower than the hardcoded 90s
event timeout:

1. proc.kill('SIGKILL') only kills the wrapper process the spawn
   returned, not the opencode-linux-*/bin/opencode child it execs into.
   The child keeps holding port 4096, so the next spawnOpencodeServer()
   fails with "Failed to start server on port 4096" / EADDRINUSE.
   Fix: spawn detached and signal the whole process group via
   process.kill(-pid, 'SIGKILL') in a new killProcessTree() helper.

2. IDLE_TIMEOUT_MS = 90_000 is hardcoded. For a local 31B model the
   first prompt's time-to-first-token routinely exceeds that, tripping
   the timeout. Fix: read OPENCODE_IDLE_TIMEOUT_MS from env, default
   300_000 (5 min) — generous for cloud APIs, just enough for local.

Per-group override goes in container.json env (e.g. "600000" for a
slow local box), no rebuild needed since src/ is bind-mounted.

Same bugs exist on origin/providers — should be ported upstream.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 12:54:51 +02:00
gavrielc c7a7a709ed Merge main into providers
Bring providers up to date with main, including the channel-inbound
attachment path-traversal fix.

Resolved conflicts:
- .claude/skills/add-codex/SKILL.md: took main (newer CODEX_VERSION,
  more accurate schema docs)
- container/Dockerfile: kept main's stratified pnpm-install layers for
  caching, added a separate layer for @openai/codex, bumped Dockerfile
  CODEX_VERSION default to 0.124.0 to match SKILL.md
2026-04-28 13:53:47 +03:00
gavrielc 7c8d220115 Merge pull request #1966 from IamAdamJowett/fix/codex-resolve-imports-and-local-md
fix(codex-provider): resolve @-imports and load CLAUDE.local.md
2026-04-24 15:32:14 +03:00
Adam 2d2c3204bc fix(codex-provider): don't double-append global CLAUDE.md when @-imports resolve it
readAgentAndGlobalClaudeMd was appending /workspace/global/CLAUDE.md
explicitly at the end, but since @-import resolution was added the
group's own CLAUDE.md already pulls global in via its default
`@./.claude-global.md` import (see src/group-init.ts). Result:
non-main groups got global instructions inlined twice, wasting
context tokens and occasionally producing contradictory repeated
guidance.

Drop the explicit global append. Groups that want global content
import it via the @-directive in their CLAUDE.md (the default) —
groups that intentionally don't import it now correctly skip it,
matching Claude-backed agent behaviour. The NANOCLAW_IS_MAIN
env branch also becomes unnecessary and is removed.

Addresses Codex Review P2 on qwibitai/nanoclaw#1966.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:45:16 +10:00
Adam 4836cb59df fix(codex-provider): resolve @-imports and load CLAUDE.local.md
The Codex app-server doesn't expand Claude Code's @-import syntax and
doesn't auto-load CLAUDE.local.md from the working directory. Left
alone, Codex-backed agents saw only the raw import directives as
literal text — no composed CLAUDE.md, no module fragments, no
per-group memory. The Claude provider worked because Claude Code
resolves both natively; anything non-Claude was running half-blind.

Adds a pure resolveClaudeImports(content, baseDir) helper that inlines
`^@<path>$` directives relative to the file they live in, recursing
into imported files and breaking cycles. readAgentAndGlobalClaudeMd
now uses it on the group CLAUDE.md, then appends CLAUDE.local.md
(same resolution base), then the global CLAUDE.md.

Missing or cyclic imports are dropped silently (empty text) rather
than left as raw `@path` lines, which would confuse the model.

Fixes the symptom where a Codex-backed agent with a well-written
CLAUDE.local.md (e.g. image-delivery discipline, wiki instructions)
behaved as if none of it existed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:33:43 +10:00
gavrielc c20213a133 docs(add-codex): fix Dockerfile install step — separate RUN block, not combined list
Keep this in sync with main. The skill's prior instruction (append to a
combined `pnpm install -g` block) no longer matches main's Dockerfile, which
splits each global CLI into its own RUN layer for cache granularity. Update
to add a standalone RUN block for Codex that matches the existing pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 21:38:40 +03:00
gavrielc d9ed98fd65 style: apply prettier to merged files 2026-04-22 11:53:55 +03:00
gavrielc 5ae1c33fff Merge v2 into providers
Picks up 105 commits from v2 (engage modes, sender/channel approval flows,
host-sweep heartbeat lifecycle, setup/onecli refactor, setup-flow docs).

Retires 9 deprecated skills that moved out of this branch's scope:
add-compact, add-gmail, add-image-vision, add-pdf-reader, add-reactions,
add-telegram-swarm, add-voice-transcription, channel-formatting,
use-local-whisper.

Preserves providers-branch code: codex provider (6 files), opencode
provider + MCP bridge, plus the CODEX_VERSION Dockerfile ARG.

Conflict: container/Dockerfile — combined v2's CLAUDE_CODE_VERSION bump
(2.1.112 → 2.1.116) with the branch-local CODEX_VERSION ARG.
2026-04-22 11:52:37 +03:00
gavrielc af542adad5 Merge pull request #1843 from chiptoe-svg/feat/codex-provider
feat(providers): add codex provider via app-server JSON-RPC
2026-04-20 23:45:04 +03:00
Chip Tonkin 894b154e41 fix(codex): remove client-driven compaction threshold
The 40 000-input-token threshold and its supporting plumbing
(cumulativeInputTokens counter, thread/tokenUsage/updated handler,
setInputTokens callback on runOneTurn) were SDK-era residue. In the
SDK implementation we ran custom summarize-and-restart around 40k
because the SDK had no native compaction; that reasoning stopped
applying once we switched to app-server's native thread/compact/start.

With that migration the threshold wasn't removed, only re-plumbed —
calling thread/compact/start from the client every time we crossed
an arbitrary 40k. On 128k-context models that over-compacts by
~3× relative to the actual context window, adding latency for no
durability gain.

Trust app-server to handle its own compaction (same posture as the
Claude provider trusts Claude Code's auto_compact). If empirical
behavior shows turns failing at the context wall, we'll revisit with
a server-notification-driven trigger rather than a client-side
threshold.

Also removes now-unused imports (sendCodexRequest, local log helper).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 17:50:24 -04:00
Chip Tonkin 831ef88f16 fix(codex): harden resume fallback and MCP config.toml escaping
Surfaced by adversarial review of the PR diff.

1. thread/resume fallback (startOrResumeCodexThread). Previously fell back
   to thread/start on ANY resume error, silently discarding session state
   for transient / auth / version-mismatch failures that should surface.
   Now gated on STALE_THREAD_RE — the same pattern CodexProvider
   .isSessionInvalid uses on the caller side — and re-throws everything
   else so the poll-loop can decide what to do.

2. MCP config.toml (writeCodexMcpConfigToml). Raw string interpolation for
   command / args / env values would produce invalid TOML on unescaped
   quotes or backslashes, and silently reinterpret the value if a newline
   snuck in. Now routed through a new tomlBasicString helper that escapes
   `"` and `\` per TOML basic-string rules, and rejects newlines with a
   clear error rather than mask misconfiguration.

STALE_THREAD_RE moves to codex-app-server.ts (exported) since both files
need it; codex.ts imports to keep the two detection paths in sync.

Tests: tomlBasicString (quotes, backslashes, escape-order, newline
rejection) and STALE_THREAD_RE (stale vs transient messages).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 13:02:25 -04:00
Chip Tonkin d05923f274 feat(skills): /add-codex — install Codex as the agent provider
Ports the Codex provider files from the providers branch into trunk,
wires host and container barrels, updates the Dockerfile to install
the Codex CLI, and documents the three auth paths (ChatGPT
subscription, OPENAI_API_KEY, BYO OpenAI-compatible endpoint).

Mirrors the /add-opencode skill shape so the two non-Claude provider
install paths look familiar. No agent-runner package dependency —
Codex is a CLI binary, not a library.

Requested by the maintainer on the PR thread — the skill lands
alongside feat(providers): add codex provider via app-server JSON-RPC
so users have a guided install path when trunk eventually picks up
the provider.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:31:08 -04:00
Chip Tonkin aba618215d fix(codex): load /workspace/global/CLAUDE.md for non-main groups
Codex's app-server doesn't expand Claude Code's `@-import` syntax, so
the previous loader only passed group CLAUDE.md — global content never
reached the model. This mirrors the OpenCode provider's
readClaudeMdForPrompt: load group CLAUDE.md, then (unless NANOCLAW_IS_MAIN=1)
append /workspace/global/CLAUDE.md.

The literal `@./.claude-global.md` line at the top of group CLAUDE.md is
left in place. OpenCode does the same — keeps the two non-Claude
providers behaviorally consistent, and upstream may revise the group
template later.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:12:08 -04:00
Chip Tonkin 138c277fae fix(codex): remove no-op model_provider_base_url override
`-c model_provider_base_url=...` is not a valid Codex config key. Codex
uses nested paths (e.g. `model_providers.<name>.base_url`) per the
`-c` help text, so the override was creating a top-level TOML entry
that Codex never reads.

The override was also redundant: Codex's built-in `openai` provider
honors the `OPENAI_BASE_URL` env var natively (standard OpenAI SDK
convention), verified against codex-cli 0.118 — a request with
`OPENAI_BASE_URL=http://127.0.0.1:9/v1` tries to reach exactly that URL.

The host provider already forwards `OPENAI_BASE_URL` into the container
(src/providers/codex.ts), so BYO-endpoint users keep their config path
without this broken `-c` flag in the way.

Net: -5 LOC, one less silent-fail branch, no user-visible change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:10:07 -04:00
Chip Tonkin 1e7cb8b8c8 feat(providers): add codex provider via app-server JSON-RPC
Adds `codex` as a third AgentProvider alongside `claude` and `opencode`.

## Goal

Bring Codex up to the same feature bar as the Claude Agent SDK integration:
persistent sessions, streaming output, MCP tool access, and native
compaction — all driven through the AgentProvider interface without
polluting the poll-loop with per-SDK quirks.

## Why not the @openai/codex-sdk

The SDK-wrapping approach (sketched in agent-runner-details.md) was
tried first but fell short of Claude Agent SDK's feature set on
several fronts. The `codex app-server` JSON-RPC protocol closes most
of the gap:

| Capability                  | Claude SDK | Codex SDK | Codex app-server |
|-----------------------------|:----------:|:---------:|:----------------:|
| Session resume              |     ✓      |     ✓     |        ✓         |
| Streaming event output      |     ✓      |     ✓     |        ✓         |
| Mid-turn input push         |     ✓      |     ✗     | queue + drain*   |
| MCP servers                 |     ✓      |  config   |     config       |
| Approval hooks              |    hooks   |  limited  |  server reqs     |
| Native compaction trigger   |    auto    |     ✗     |        ✓         |
| Multi-turn subagents        |    Task    |     ✗     |   spawnAgent     |
| Config overrides            |     API    |     JS    |   -c flags       |

*Codex turns don't accept mid-turn input. The provider queues push()
 and drains between turns — matches the opencode provider pattern and
 the poll-loop doesn't dispatch new messages mid-turn anyway.

## Known shortcomings (both tractable in follow-ups)

**No native file/web tools.** Claude ships Read/Write/Edit/Glob/Grep/
WebFetch/WebSearch in-SDK. Codex (both SDK and app-server) leaves the
agent to shell out via bash — slower, messier output. Mitigation: a
follow-up can expose these as MCP tools on the container side, reusing
the existing `send_message` / `send_file` tool plumbing.

**Higher tool-call latency.** Every tool call round-trips through
JSON-RPC over stdio (~10-50ms per hop vs Claude's in-process dispatch).
Mitigation: batch MCP calls, pipeline commands — same playbook as
opencode.

Neither blocks feature parity for the common conversational + MCP
cases this PR targets.

## Files

- container/agent-runner/src/providers/codex-app-server.ts
    JSON-RPC transport: spawn, request/response, notification dispatch,
    auto-approval, thread lifecycle, MCP config.toml writer. Kept
    separate so it can be unit-tested without the full provider.
- container/agent-runner/src/providers/codex.ts
    CodexProvider implementing AgentProvider. Emits `activity` on every
    notification so idle timer stays honest. isSessionInvalid matches
    on stale-thread-ID errors for clean recovery.
- container/agent-runner/src/providers/codex.factory.test.ts
    Factory + isSessionInvalid + supportsNativeSlashCommands coverage.
- src/providers/codex.ts
    Host-side container contribution. Per-session ~/.codex copy with
    auth.json copied from host, so the container rewrites config.toml
    on every wake without touching the host's. Forwards OPENAI_API_KEY
    / CODEX_MODEL / OPENAI_BASE_URL.
- container/Dockerfile
    Pins CODEX_VERSION=0.121.0, adds @openai/codex to the pnpm global
    install block alongside claude-code/agent-browser/vercel.

## Validation

Smoke-tested end-to-end against real codex-cli 0.118 on macOS: observed
init → activity → progress → result events with a stable thread ID as
continuation, result text matched expected output. Unit tests: 26 pass
in agent-runner, 137 in host. Typecheck + prettier clean on all added
files.
2026-04-19 10:30:47 -04:00
gavrielc eb0055a0b0 Merge remote-tracking branch 'origin/v2' into providers-sync 2026-04-18 22:07:32 +03:00
gavrielc ef6ea87628 Merge branch 'v2' into providers 2026-04-18 19:15:58 +03:00
gavrielc b29df213ad Merge remote-tracking branch 'origin/v2' into providers 2026-04-18 15:59:25 +03:00
gavrielc dd53875574 fix(providers): restore opencode files that v2 merge deleted
The v2 merge brought in commit e0258e8 ('move opencode provider off
v2 trunk') which correctly removed opencode from v2 but also applied
that deletion on this branch, where opencode must live. Restored:

- container/agent-runner/src/providers/opencode.ts
- container/agent-runner/src/providers/mcp-to-opencode.ts
- container/agent-runner/src/providers/mcp-to-opencode.test.ts
- src/providers/opencode.ts
- @opencode-ai/sdk dep in container/agent-runner/package.json
- opencode self-registration imports in both providers barrels

All 21 container tests now pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 14:57:38 +03:00
gavrielc a1ce73c376 Merge branch 'v2' into providers 2026-04-18 14:54:54 +03:00
gavrielc 48e4172899 refactor(providers-branch): split opencode test into its own file
Moves the opencode test case out of factory.test.ts into
opencode.factory.test.ts so the /add-opencode skill can install the
opencode bundle as a pure file copy — no in-place merging of the shared
factory.test.ts required. Keeps factory.test.ts identical to v2 trunk.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 14:13:04 +03:00
118 changed files with 3640 additions and 12956 deletions
-161
View File
@@ -1,161 +0,0 @@
---
name: add-codex
description: Use Codex (CLI + AppServer) as the full agent provider — planning, tool orchestration, native compaction, MCP tools, session resume — in place of the Claude Agent SDK. ChatGPT subscription or OPENAI_API_KEY. Per-group via agent_provider. Distinct from using OpenAI as an MCP tool (where Claude remains the planner).
---
# Codex agent provider
NanoClaw runs agents in a long-lived **poll loop** inside the container. The backend is selected with **`AGENT_PROVIDER`** (`claude` | `opencode` | `codex` | `mock`).
Trunk ships with only the `claude` provider baked in. This skill copies the Codex provider files in from the `providers` branch, wires them into the host and container barrels, updates the Dockerfile to install the Codex CLI, and rebuilds the image.
The Codex provider runs `codex app-server` as a child process and speaks JSON-RPC over stdio. That gives it native session resume, streaming events, MCP tool access, and `thread/compact/start` compaction — same feature bar as the Claude Agent SDK, without the Anthropic-only lock-in.
## Install
### Pre-flight
If all of the following are already present, skip to **Configuration**:
- `src/providers/codex.ts`
- `container/agent-runner/src/providers/codex.ts`
- `container/agent-runner/src/providers/codex-app-server.ts`
- `container/agent-runner/src/providers/codex.factory.test.ts`
- `import './codex.js';` line in `src/providers/index.ts`
- `import './codex.js';` line in `container/agent-runner/src/providers/index.ts`
- `ARG CODEX_VERSION` and `"@openai/codex@${CODEX_VERSION}"` in the pnpm global-install block in `container/Dockerfile`
Missing pieces — continue below. All steps are idempotent; re-running is safe.
### 1. Fetch the providers branch
```bash
git fetch origin providers
```
### 2. Copy the Codex source files
Wholesale copies (owned entirely by this skill — user edits to these files won't survive a re-run, as designed):
```bash
git show origin/providers:src/providers/codex.ts > src/providers/codex.ts
git show origin/providers:container/agent-runner/src/providers/codex.ts > container/agent-runner/src/providers/codex.ts
git show origin/providers:container/agent-runner/src/providers/codex-app-server.ts > container/agent-runner/src/providers/codex-app-server.ts
git show origin/providers:container/agent-runner/src/providers/codex.factory.test.ts > container/agent-runner/src/providers/codex.factory.test.ts
```
### 3. Append the self-registration imports
Each barrel gets one line — alphabetical placement keeps diffs small.
`src/providers/index.ts`:
```typescript
import './codex.js';
```
`container/agent-runner/src/providers/index.ts`:
```typescript
import './codex.js';
```
### 4. Add the Codex CLI to the container Dockerfile
Two edits to `container/Dockerfile`, both idempotent (skip if already present):
**(a)** In the "Pin CLI versions" ARG block (around line 18), add after `ARG CLAUDE_CODE_VERSION=...`:
```dockerfile
ARG CODEX_VERSION=0.124.0
```
**(b)** Add a new standalone `RUN` block for the Codex CLI, after the existing per-CLI install blocks (around line 106, right after the `@anthropic-ai/claude-code` block). The Dockerfile splits each global CLI into its own layer for cache granularity — keep that pattern; do not collapse them into a single combined `pnpm install -g` call:
```dockerfile
RUN --mount=type=cache,target=/root/.cache/pnpm \
pnpm install -g "@openai/codex@${CODEX_VERSION}"
```
Note: **no agent-runner package dependency** — Codex is a CLI binary, not a library. Unlike OpenCode, there's nothing to add to `container/agent-runner/package.json`.
### 5. Build
```bash
pnpm run build # host
pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit # container typecheck
./container/build.sh # agent image
```
## Configuration
Codex supports two primary auth paths and one experimental BYO-endpoint path. Pick the one that matches your setup.
### Option A — ChatGPT subscription (recommended for individuals)
On the host (not inside the container), run Codex's OAuth login:
```bash
codex login
```
This writes `~/.codex/auth.json` with a subscription token. The host-side Codex provider ([src/providers/codex.ts](../../../src/providers/codex.ts)) copies `auth.json` into a per-session `~/.codex` directory mounted into the container — your host's own Codex CLI is never touched.
No `.env` variables required for this mode.
### Option B — API key (recommended for CI or API billing)
```env
OPENAI_API_KEY=sk-...
CODEX_MODEL=gpt-5.4-mini
```
The host forwards both variables into the container. If both subscription (`auth.json`) and `OPENAI_API_KEY` are present, Codex prefers the subscription.
### Option C — BYO OpenAI-compatible endpoint (experimental)
Codex's built-in `openai` provider honors the `OPENAI_BASE_URL` env var directly. Point it at any OpenAI-compatible endpoint — Groq, Together, self-hosted vLLM, an OpenAI proxy, etc.
```env
OPENAI_API_KEY=...
OPENAI_BASE_URL=https://api.groq.com/openai/v1
CODEX_MODEL=llama-3.3-70b-versatile
```
Codex also ships first-class local-runner flags — `codex --oss --local-provider ollama` or `--local-provider lmstudio` — that auto-detect a local server. To use those inside NanoClaw, set `CODEX_MODEL` to a model your local runner serves and add the corresponding base URL; see the Codex CLI docs for the full `model_provider = oss` configuration.
**Experimental caveat:** tool-calling quality depends on the model and endpoint. Not every OpenAI-compat provider implements the full function-calling spec, and smaller models (< 30B) often struggle with multi-step tool orchestration. Test before committing.
### Per group / per session
Set `"provider": "codex"` in the group's **`container.json`** (`groups/<folder>/container.json`) — the in-container runner reads `provider` from there, not from the DB. The DB columns **`agent_groups.agent_provider`** and **`sessions.agent_provider`** (session overrides group) only drive host-side provider contribution — per-session `~/.codex` mount, `OPENAI_*` / `CODEX_MODEL` env passthrough — and do not propagate into `container.json` at spawn time. Set both, or just edit `container.json`; if they disagree, the runner uses `container.json` and the host-side resolver falls back through session → group → `container.json``'claude'`.
`CODEX_MODEL` applies process-wide via `.env`; if you need different models for different groups, set them via `container_config.env` on the group.
Extra MCP servers still come from **`NANOCLAW_MCP_SERVERS`** / `container_config.mcpServers` on the host. The runner merges them into the same `mcpServers` object passed to all providers.
## Operational notes
- **Spawn-per-query:** Codex's app-server is spawned fresh per query invocation, matching the OpenCode pattern. No long-lived daemon to keep healthy across sessions.
- **Per-session `~/.codex` isolation:** each group gets its own copy of the host's `auth.json`. The container can rewrite `config.toml` freely on every wake without touching the host's Codex config.
- **Native compaction:** kicks in automatically at 40K cumulative input tokens between turns, via `thread/compact/start`. If compaction fails, the provider logs and continues uncompacted — no fatal error.
- **Approvals:** auto-accepted inside the container (the container is the sandbox; same posture as Claude/OpenCode).
- **Mid-turn input:** Codex turns don't accept mid-turn messages. Follow-up `push()` calls queue and drain between turns, matching the OpenCode pattern. The poll-loop only pushes between turns anyway, so no messages are dropped.
- **Stale thread recovery:** `isSessionInvalid` matches on stale-thread-ID errors (`thread not found`, `unknown thread`, etc.) so a cold-started app-server can recover cleanly when it sees a stored continuation it no longer has.
## Verify
```bash
grep -q "./codex.js" container/agent-runner/src/providers/index.ts && echo "container barrel: OK"
grep -q "./codex.js" src/providers/index.ts && echo "host barrel: OK"
grep -q "@openai/codex@" container/Dockerfile && echo "Dockerfile install: OK"
cd container/agent-runner && bun test src/providers/codex.factory.test.ts && cd -
```
After the image rebuild, set `agent_provider = 'codex'` on a test group and send a message. Successful round-trip looks like:
- `init` event with a stable thread ID as continuation
- One or more `activity` / `progress` events during the turn
- `result` event with the model's reply
If the agent hangs or errors, check `~/.codex/auth.json` exists on the host (Option A) or that `OPENAI_API_KEY` is forwarding correctly (Option B) — `docker exec` into a running container and `env | grep -i openai` to confirm.
-62
View File
@@ -1,62 +0,0 @@
# Remove DeltaChat
## 1. Disable the adapter
Comment out the import in `src/channels/index.ts`:
```typescript
// import './deltachat.js';
```
## 2. Remove credentials
Remove the `DC_*` lines from `.env`:
```bash
DC_EMAIL
DC_PASSWORD
DC_IMAP_HOST
DC_IMAP_PORT
DC_SMTP_HOST
DC_SMTP_PORT
```
## 3. Rebuild and restart
```bash
pnpm run build
# Linux
systemctl --user restart nanoclaw
# macOS
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
```
## 4. Remove account data (optional)
To fully remove all account data including DeltaChat encryption keys:
```bash
rm -rf dc-account/
```
> **Warning:** This deletes the Autocrypt keys. Contacts who have verified your bot's key will need to re-verify if the same email address is re-used with a new account.
To keep the account for later reinstall, leave `dc-account/` intact.
## 5. Remove the package (optional)
```bash
pnpm remove @deltachat/stdio-rpc-server
```
## Verification
After removal, confirm the adapter is no longer starting:
```bash
grep "deltachat" logs/nanoclaw.log | tail -5
```
Expected: no `Channel adapter started` entry after the last restart.
-254
View File
@@ -1,254 +0,0 @@
---
name: add-deltachat
description: Add DeltaChat channel integration via @deltachat/stdio-rpc-server. Native adapter — no Chat SDK bridge. Email-based messaging with end-to-end encryption.
---
# Add DeltaChat Channel
The adapter drives the `@deltachat/stdio-rpc-server` JSON-RPC subprocess directly — pure Node.js against the DeltaChat core library. Messages are delivered over email with Autocrypt/OpenPGP encryption.
## Install
### Pre-flight (idempotent)
Skip to **Credentials** if all of these are already in place:
- `src/channels/deltachat.ts` exists
- `src/channels/index.ts` contains `import './deltachat.js';`
- `@deltachat/stdio-rpc-server` is listed in `package.json` dependencies
Otherwise continue. Every step below is safe to re-run.
### 1. Fetch the channels branch
```bash
git fetch origin channels
```
### 2. Copy the adapter
```bash
git show origin/channels:src/channels/deltachat.ts > src/channels/deltachat.ts
```
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if already present):
```typescript
import './deltachat.js';
```
### 4. Install the adapter package (pinned)
```bash
pnpm install @deltachat/stdio-rpc-server@2.49.0
```
### 5. Build
```bash
pnpm run build
```
## Account Setup
A dedicated email account is strongly recommended — it will accumulate DeltaChat-formatted messages and store encryption keys. Not all providers work well with DeltaChat; check https://providers.delta.chat/ before picking one.
**Default security modes:** IMAP uses SSL/TLS (port 993), SMTP uses STARTTLS (port 587). Both are configurable via `.env` — see Credentials below.
To find the correct hostnames for a domain:
```bash
node -e "require('dns').resolveMx('example.com', (e,r) => console.log(r))"
```
Most providers publish their IMAP/SMTP hostnames in their help docs under "manual setup" or "IMAP access."
## Credentials
Add to `.env`:
```bash
DC_EMAIL=bot@example.com
DC_PASSWORD=your-app-password
DC_IMAP_HOST=imap.example.com
DC_IMAP_PORT=993
DC_IMAP_SECURITY=1 # 1=SSL/TLS (default), 2=STARTTLS, 3=plain
DC_SMTP_HOST=smtp.example.com
DC_SMTP_PORT=587
DC_SMTP_SECURITY=2 # 2=STARTTLS (default), 1=SSL/TLS, 3=plain
```
Security settings are applied on every startup, so changing them in `.env` and restarting takes effect without wiping the account.
Sync to container: `mkdir -p data/env && cp .env data/env/env`
### Optional settings
The following are read from the process environment (not `.env`). To override them, add `Environment=` lines to the systemd service unit or your launchd plist:
| Variable | Default | Description |
|----------|---------|-------------|
| `DC_ACCOUNT_DIR` | `dc-account` | Directory for DeltaChat account data (IMAP state, keys, blobs) |
| `DC_DISPLAY_NAME` | `NanoClaw` | Bot display name shown in DeltaChat |
| `DC_AVATAR_PATH` | _(none)_ | Absolute path to avatar image; set at startup only |
The `/set-avatar` command (send an image with that caption) is the easiest way to set the avatar at runtime without modifying the service file. Only users with `owner` or global `admin` role can use it.
### Restart
```bash
# Linux
systemctl --user restart nanoclaw
# macOS
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
```
On first start the adapter configures the email account (IMAP/SMTP credentials, calls `configure()`). Subsequent starts skip straight to `startIo()`. Account data is stored in `dc-account/` in the project root (or your `DC_ACCOUNT_DIR`).
## Wiring
### DMs
**DeltaChat contacts cannot be added by email alone** — to start a chat, the user must open the bot's invite link in their DeltaChat app or scan its QR code. This triggers the SecureJoin handshake.
#### Step 1 — Get the invite link
After the service starts, the adapter logs the invite URL and writes a QR SVG:
```bash
grep "invite link" logs/nanoclaw.log | tail -1
# url field contains the https://i.delta.chat/... invite link
# also written to dc-account/invite-qr.svg (or $DC_ACCOUNT_DIR/invite-qr.svg)
```
The invite URL is stable (tied to the bot's email and encryption keys) so it stays valid across restarts.
#### Step 2 — Add the bot in DeltaChat
Two options for the user to connect:
- **Link**: Copy the `https://i.delta.chat/...` URL and open it on the device running DeltaChat. The app recognises it and shows a "Start chat" prompt.
- **QR code**: Open `dc-account/invite-qr.svg` in a browser or image viewer, display it on screen, and scan it from the DeltaChat app using the QR-scan button on the new-chat screen.
After accepting, DeltaChat exchanges keys and creates the chat automatically.
#### Step 3 — Wire the chat to an agent
Once the first message arrives the router auto-creates a `messaging_groups` row. Look up the chat ID:
```bash
sqlite3 data/v2.db \
"SELECT platform_id, name FROM messaging_groups WHERE channel_type='deltachat' AND is_group=0 ORDER BY created_at DESC LIMIT 5"
```
Then run `/init-first-agent` — it creates the agent group, grants the user owner access, and wires the messaging group in one step:
```bash
pnpm exec tsx scripts/init-first-agent.ts \
--channel deltachat \
--user-id deltachat:user@example.com \
--platform-id <platform_id from above> \
--display-name "Your Name"
```
### Groups
Add the bot email to a DeltaChat group. When any member sends a message, the router creates a `messaging_groups` row with `is_group = 1`. Run `/manage-channels` to wire it to an agent group.
## Next Steps
If you're in the middle of `/setup`, return to the setup flow now.
Otherwise, run `/init-first-agent` to create an agent and wire it to your DeltaChat DM (see Wiring above), or `/manage-channels` to wire this channel to an existing agent group.
## Channel Info
- **type**: `deltachat`
- **terminology**: DeltaChat calls them "chats" (1:1 DMs) and "groups"
- **supports-threads**: no — DeltaChat has no thread model
- **platform-id-format**: numeric chat ID as a string (e.g. `"12"`) — the DeltaChat core's internal chat identifier
- **user-id-format**: `deltachat:{email}` — the contact's email address
- **how-to-find-id**: Send a message from DeltaChat to the bot email, then query `messaging_groups` as shown above
- **typical-use**: Personal assistant over DeltaChat DMs; small groups where participants use DeltaChat
- **default-isolation**: One agent per bot identity. Multiple chats with the same operator can share an agent group; groups with other people should typically use `isolated` session mode
### Features
- File attachments — inbound and outbound; inbound waits up to 30 seconds for large-message download to complete
- Invite link logged on every startup — URL + QR SVG written to `dc-account/invite-qr.svg`; see Wiring for the bootstrap flow
- `/set-avatar` — send an image with this caption to change the bot's DeltaChat avatar (admin/owner only)
- Connectivity watchdog — restarts IO if IMAP goes quiet for 20 minutes or connectivity drops below threshold for two consecutive 5-minute checks
- Network nudge — `maybeNetwork()` called every 10 minutes to recover from prolonged idle
Not supported: DeltaChat reactions, message editing/deletion, read receipts.
### Connectivity model
`isConnected()` returns `true` when the internal connectivity value is ≥ 3000:
| Range | Meaning |
|-------|---------|
| 10001999 | Not connected |
| 20002999 | Connecting |
| 30003999 | Working (IMAP fetching) |
| ≥ 4000 | Fully connected (IMAP IDLE) |
## Troubleshooting
### Adapter not starting — credentials missing
```bash
grep "Channel credentials missing" logs/nanoclaw.log | grep deltachat
```
All six required vars (`DC_EMAIL`, `DC_PASSWORD`, `DC_IMAP_HOST`, `DC_IMAP_PORT`, `DC_SMTP_HOST`, `DC_SMTP_PORT`) must be present in `.env`.
### Account configure fails
```bash
grep "DeltaChat" logs/nanoclaw.log | tail -20
```
Common causes:
- Wrong IMAP/SMTP hostnames — double-check provider docs
- App password not generated — Gmail and some others require this when 2FA is enabled
- Port/security mismatch — defaults are port 993 + SSL/TLS for IMAP and port 587 + STARTTLS for SMTP; override with `DC_IMAP_PORT`/`DC_IMAP_SECURITY` or `DC_SMTP_PORT`/`DC_SMTP_SECURITY` in `.env`
### Provider uses SMTP port 465 (SSL/TLS) instead of 587
Set `DC_SMTP_SECURITY=1` and `DC_SMTP_PORT=465` in `.env`, then restart.
### Messages not arriving
1. Check the service is running and the adapter started: `grep "Channel adapter started.*deltachat" logs/nanoclaw.log`
2. Check connectivity: `grep "DeltaChat: IO started" logs/nanoclaw.log`
3. Check the sender has been granted access — run `/init-first-agent` to create their user record and wire the chat
4. Verify the messaging group is wired: `sqlite3 data/v2.db "SELECT mg.platform_id, mga.agent_group_id FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id WHERE mg.channel_type='deltachat'"`
### Stale lock file after crash
```bash
rm -f dc-account/accounts.lock
systemctl --user restart nanoclaw
```
### Bot not responding after restart
The account is already configured — IO restarts automatically on service start. If the RPC subprocess is stuck, restart the service. Check for errors:
```bash
grep "DeltaChat" logs/nanoclaw.error.log | tail -20
```
### Messages received but agent not responding
The messaging group exists but may not be wired to an agent group. Run:
```bash
sqlite3 data/v2.db "SELECT id, platform_id, name FROM messaging_groups WHERE channel_type='deltachat'"
```
If the group has no entry in `messaging_group_agents`, wire it with `/manage-channels`.
-54
View File
@@ -1,54 +0,0 @@
# Verify DeltaChat
## 1. Check the adapter started
```bash
grep "Channel adapter started.*deltachat" logs/nanoclaw.log | tail -1
```
Expected: `Channel adapter started { channel: 'deltachat', type: 'deltachat' }`
## 2. Check IMAP/SMTP connectivity
Replace with your provider's hostnames from `.env`:
```bash
DC_IMAP=$(grep '^DC_IMAP_HOST=' .env | cut -d= -f2)
DC_SMTP=$(grep '^DC_SMTP_HOST=' .env | cut -d= -f2)
bash -c "echo >/dev/tcp/$DC_IMAP/993" && echo "IMAP open" || echo "IMAP blocked"
bash -c "echo >/dev/tcp/$DC_SMTP/587" && echo "SMTP open" || echo "SMTP blocked"
```
## 3. End-to-end message test
1. Open DeltaChat on your device
2. Add the bot email address as a contact
3. Send a message
4. The bot should respond within a few seconds
If nothing arrives, check:
```bash
grep "DeltaChat" logs/nanoclaw.log | tail -20
grep "DeltaChat" logs/nanoclaw.error.log | tail -10
```
## 4. Check messaging group was created
```bash
sqlite3 data/v2.db \
"SELECT id, platform_id, name FROM messaging_groups WHERE channel_type='deltachat' ORDER BY created_at DESC LIMIT 5"
```
If a row appears, the inbound routing is working. If not, the adapter isn't receiving the message — check logs for `DeltaChat: error handling incoming message`.
## 5. Verify user access
If the message arrived but the agent didn't respond, the sender may not have access:
```bash
sqlite3 data/v2.db "SELECT id, display_name FROM users WHERE id LIKE 'deltachat:%'"
```
Grant access as shown in the SKILL.md "Grant user access" section.
+4 -19
View File
@@ -72,41 +72,26 @@ pnpm run build
### Event Subscriptions
8. Go to **Event Subscriptions** and toggle **Enable Events**
9. **Webhook mode:** set the **Request URL** to `https://your-domain/webhook/slack` — Slack will send a verification challenge; it must pass before you can save. For **Socket Mode** (below), skip the Request URL.
9. Set the **Request URL** to `https://your-domain/webhook/slack` — Slack will send a verification challenge; it must pass before you can save
10. Under **Subscribe to bot events**, add:
- `message.channels`, `message.groups`, `message.im`, `app_mention`
11. Click **Save Changes**
12. Slack will show a banner asking you to **reinstall the app** — click it to apply the new event subscriptions
### Socket Mode (optional — no public URL)
Socket Mode delivers events over an outbound WebSocket the bot opens to Slack, so the host needs **no public HTTPS endpoint** — ideal for local dev or a host behind NAT/a firewall. Setting `SLACK_APP_TOKEN` is what flips the adapter into Socket Mode; without it the adapter stays in webhook mode.
13. Go to **Basic Information** > **App-Level Tokens** > **Generate Token and Scopes**, add the `connections:write` scope, and copy the token (`xapp-...`)
14. Go to **Socket Mode** and toggle **Enable Socket Mode** on
15. Keep **Event Subscriptions** enabled with the bot events above — under Socket Mode no Request URL is required
### Configure environment
Add to `.env`**webhook mode**:
Add to `.env`:
```bash
SLACK_BOT_TOKEN=xoxb-your-bot-token
SLACK_SIGNING_SECRET=your-signing-secret
```
…or **Socket Mode** (no public URL; signing secret optional):
```bash
SLACK_BOT_TOKEN=xoxb-your-bot-token
SLACK_APP_TOKEN=xapp-your-app-level-token
```
Sync to container: `mkdir -p data/env && cp .env data/env/env`
### Webhook server (webhook mode only)
### Webhook server
In **webhook mode** the Chat SDK bridge automatically starts a shared webhook server on port 3000 (configurable via `WEBHOOK_PORT` env var). The server handles `/webhook/slack` for Slack and other webhook-based adapters. This port must be publicly reachable from the internet for Slack to deliver events. **In Socket Mode this is not needed** — skip this section if you set `SLACK_APP_TOKEN`.
The Chat SDK bridge automatically starts a shared webhook server on port 3000 (configurable via `WEBHOOK_PORT` env var). The server handles `/webhook/slack` for Slack and other webhook-based adapters. This port must be publicly reachable from the internet for Slack to deliver events.
If running locally, discuss options for exposing the server — e.g. ngrok (`ngrok http 3000`), Cloudflare Tunnel, or a reverse proxy on a VPS. The resulting public URL becomes the base for `https://your-domain/webhook/slack`.
+1 -1
View File
@@ -57,7 +57,7 @@ groups: () => import('./groups.js'),
### 5. Install the adapter packages (pinned)
```bash
pnpm install @whiskeysockets/baileys@7.0.0-rc.9 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0
pnpm install @whiskeysockets/baileys@6.17.16 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0
```
### 6. Build
+15
View File
@@ -0,0 +1,15 @@
You are a NanoClaw agent. Your name, destinations, and message-sending rules are provided in the runtime system prompt at the top of each turn.
## Communication
Be concise. Prefer outcomes over play-by-play; when the work is done, the final message should be about the result.
When you produce a file for the user in the workspace — a document, export, or asset — deliver it with `send_file` in the same turn; announcing without sending is an unfinished reply.
## Workspace
Files you create are saved in `/workspace/agent/`. Use this for notes, research, artifacts, and anything that should persist across turns in this group.
## Conversation History
The `conversations/` folder holds searchable past conversation transcripts or exchange archives for this group. Use it to recall prior context when a request references something that happened before.
+3
View File
@@ -7,6 +7,7 @@
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.116",
"@modelcontextprotocol/sdk": "^1.12.1",
"@opencode-ai/sdk": "^1.4.3",
"cron-parser": "^5.0.0",
"zod": "^4.0.0",
},
@@ -44,6 +45,8 @@
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.4.11", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-EJxSfc7D/dda/vrw8zQe4g7yVTxERktvb5SvIBlGBnKYQJGOgo9RyA/1EL3l208rHeo6jm1sdrAF0E6o/k94ug=="],
"@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="],
"@types/node": ["@types/node@22.19.17", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q=="],
+1
View File
@@ -11,6 +11,7 @@
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.116",
"@modelcontextprotocol/sdk": "^1.12.1",
"@opencode-ai/sdk": "^1.4.3",
"cron-parser": "^5.0.0",
"zod": "^4.0.0"
},
@@ -0,0 +1,162 @@
import { describe, expect, it, afterEach } from 'bun:test';
import fs from 'fs';
import os from 'os';
import path from 'path';
import {
type AppServer,
attachCodexAutoApproval,
buildCodexProcessEnv,
tomlBasicString,
writeCodexConfigToml,
} from './codex-app-server.js';
let tmpHome: string | null = null;
const originalHome = process.env.HOME;
afterEach(() => {
process.env.HOME = originalHome;
if (tmpHome) {
fs.rmSync(tmpHome, { recursive: true, force: true });
tmpHome = null;
}
});
describe('Codex config TOML', () => {
it('escapes basic strings', () => {
expect(tomlBasicString('a "quoted" \\\\ value')).toBe('"a \\"quoted\\" \\\\\\\\ value"');
});
it('rejects newlines', () => {
expect(() => tomlBasicString('bad\nvalue')).toThrow(/newline/);
});
it('hardcodes danger-full-access + never and writes model, effort, and MCP servers', () => {
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-home-'));
process.env.HOME = tmpHome;
writeCodexConfigToml(
{
nanoclaw: {
command: 'bun',
args: ['run', '/app/src/mcp-tools/index.ts'],
env: { FOO: 'bar' },
},
},
{ model: 'gpt-5', effort: 'medium' },
);
const content = fs.readFileSync(path.join(tmpHome, '.codex', 'config.toml'), 'utf-8');
expect(content).toContain('sandbox_mode = "danger-full-access"');
expect(content).toContain('approval_policy = "never"');
expect(content).toContain('project_doc_max_bytes = 32768');
expect(content).toContain('model = "gpt-5"');
expect(content).toContain('model_reasoning_effort = "medium"');
expect(content).not.toContain('[sandbox_workspace_write]');
expect(content).not.toContain('writable_roots =');
expect(content).toContain('[mcp_servers.nanoclaw]');
expect(content).toContain('command = "bun"');
expect(content).toContain('args = ["run", "/app/src/mcp-tools/index.ts"]');
expect(content).toContain('[mcp_servers.nanoclaw.env]');
expect(content).toContain('FOO = "bar"');
});
});
describe('Codex auto-approval', () => {
// NanoClaw (container isolation + OneCLI) is the boundary, so the handler accepts
// every request unconditionally — even paths/commands a sandbox policy would refuse.
it('grants full filesystem + network for permission requests', () => {
const { server, writes } = fakeServer();
attachCodexAutoApproval(server);
server.serverRequestHandlers[0]({
id: 1,
method: 'item/permissions/requestApproval',
params: { permissions: { fileSystem: { read: ['/workspace/agent'], write: ['/workspace/agent'] } } },
});
const result = JSON.parse(writes[0]).result as {
permissions: { fileSystem: { read: string[]; write: string[] }; network: { enabled: boolean } };
scope: string;
};
expect(result.scope).toBe('turn');
expect(result.permissions.fileSystem.read).toEqual(['/']);
expect(result.permissions.fileSystem.write).toEqual(['/']);
expect(result.permissions.network.enabled).toBe(true);
});
it('accepts file-change and command-exec approvals regardless of path', () => {
const { server, writes } = fakeServer();
attachCodexAutoApproval(server);
server.serverRequestHandlers[0]({ id: 2, method: 'item/fileChange/requestApproval', params: { grantRoot: '/etc' } });
server.serverRequestHandlers[0]({
id: 3,
method: 'item/commandExecution/requestApproval',
params: { command: 'rm -rf /', cwd: '/' },
});
expect(JSON.parse(writes[0]).result).toEqual({ decision: 'accept' });
expect(JSON.parse(writes[1]).result).toEqual({ decision: 'accept' });
});
it('approves legacy patch and command-exec approvals regardless of path', () => {
const { server, writes } = fakeServer();
attachCodexAutoApproval(server);
server.serverRequestHandlers[0]({
id: 4,
method: 'applyPatchApproval',
params: { fileChanges: { '/etc/passwd': {} } },
});
server.serverRequestHandlers[0]({ id: 5, method: 'execCommandApproval', params: { command: 'rm -rf /', cwd: '/' } });
expect(JSON.parse(writes[0]).result).toEqual({ decision: 'approved' });
expect(JSON.parse(writes[1]).result).toEqual({ decision: 'approved' });
});
it('fails closed for unknown server requests', () => {
const { server, writes } = fakeServer();
attachCodexAutoApproval(server);
server.serverRequestHandlers[0]({ id: 6, method: 'new/unknown/request' });
const response = JSON.parse(writes[0]);
expect(response.error.message).toContain('Unhandled Codex app-server request');
});
});
describe('Codex process env', () => {
it('forwards proxy/runtime env without leaking secret-like host env', () => {
const env = buildCodexProcessEnv({
PATH: '/bin',
HOME: '/home/node',
CODEX_HOME: '/home/node/.codex',
HTTPS_PROXY: 'http://proxy',
OPENAI_API_KEY: 'sk-test',
ONECLI_API_KEY: 'onecli-secret',
SOME_TOKEN: 'token',
});
expect(env.PATH).toBe('/bin');
expect(env.HOME).toBe('/home/node');
expect(env.CODEX_HOME).toBe('/home/node/.codex');
expect(env.HTTPS_PROXY).toBe('http://proxy');
expect(env.OPENAI_API_KEY).toBeUndefined();
expect(env.ONECLI_API_KEY).toBeUndefined();
expect(env.SOME_TOKEN).toBeUndefined();
});
});
function fakeServer(): { server: AppServer; writes: string[] } {
const writes: string[] = [];
const server = {
process: { stdin: { write: (line: string) => writes.push(line) } },
readline: { close: () => {} },
pending: new Map(),
notificationHandlers: [],
exitHandlers: [],
serverRequestHandlers: [],
} as unknown as AppServer;
return { server, writes };
}
@@ -0,0 +1,441 @@
import fs from 'fs';
import path from 'path';
import { spawn, type ChildProcess } from 'child_process';
import { createInterface, type Interface as ReadlineInterface } from 'readline';
// Cap Codex's project-doc loading (AGENTS.md). The host-side composer
// (src/providers/codex-agents-md.ts) enforces the same cap at compose time —
// host and container share no modules, so the constant lives in both.
const CODEX_PROJECT_DOC_MAX_BYTES = 32 * 1024;
function log(msg: string): void {
console.error(`[codex-app-server] ${msg}`);
}
const INIT_TIMEOUT_MS = 30_000;
export const STALE_THREAD_RE = /thread\s+not\s+found|unknown\s+thread|thread[_\s]id|no such thread/i;
let nextRequestId = 1;
export interface JsonRpcResponse {
id: number | string;
result?: unknown;
error?: { code: number; message: string; data?: unknown };
}
export interface JsonRpcNotification {
method: string;
params?: Record<string, unknown>;
}
export interface JsonRpcServerRequest {
id: number | string;
method: string;
params?: Record<string, unknown>;
}
type JsonRpcMessage = JsonRpcResponse | JsonRpcNotification | JsonRpcServerRequest;
export interface AppServer {
process: ChildProcess;
readline: ReadlineInterface;
pending: Map<number | string, { resolve: (r: JsonRpcResponse) => void; reject: (e: Error) => void }>;
notificationHandlers: Array<(n: JsonRpcNotification) => void>;
serverRequestHandlers: Array<(r: JsonRpcServerRequest) => void>;
/**
* Fired when the app-server process dies (exit or spawn error). Pending
* request/response pairs are rejected separately via failPending but a
* turn in flight has NO pending request (turn/start already resolved); it
* is parked on a notification waker that a dead process will never kick.
* Without these handlers a mid-turn crash surfaces as a 10-minute turn
* timeout instead of the real exit code, after the --rm container has
* already taken the server's stderr with it.
*/
exitHandlers: Array<(err: Error) => void>;
}
export interface CodexMcpServer {
command: string;
args?: string[];
env?: Record<string, string>;
}
export type CodexReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
// Codex runs unrestricted inside the container. NanoClaw's container isolation and
// the OneCLI allow-list are the security boundary — not Codex's own sandbox/approval
// primitives (which can't run here anyway: workspace-write/read-only need user
// namespaces, which the agent containers deny). Both are hardcoded as instance-level
// defaults in config.toml; threads and turns inherit them, never override them.
const CODEX_SANDBOX_MODE = 'danger-full-access';
const CODEX_APPROVAL_POLICY = 'never';
const CODEX_ENV_ALLOWLIST = new Set([
'ALL_PROXY',
'CURL_CA_BUNDLE',
'GIT_SSL_CAINFO',
'HOME',
'HTTP_PROXY',
'HTTPS_PROXY',
'LANG',
'LC_ALL',
'NODE_EXTRA_CA_CERTS',
'NO_PROXY',
'PATH',
'PNPM_HOME',
'REQUESTS_CA_BUNDLE',
'SSL_CERT_DIR',
'SSL_CERT_FILE',
'TEMP',
'TERM',
'TMP',
'TMPDIR',
'TZ',
'USER',
'all_proxy',
'http_proxy',
'https_proxy',
'no_proxy',
'CODEX_HOME',
]);
export interface ThreadParams {
model?: string;
cwd: string;
baseInstructions?: string;
developerInstructions?: string;
}
export interface TurnParams {
threadId: string;
inputText: string;
model?: string;
effort?: string;
cwd?: string;
}
export function spawnCodexAppServer(): AppServer {
const args = ['app-server', '--listen', 'stdio://'];
log(`Spawning: codex ${args.join(' ')}`);
const proc = spawn('codex', args, {
stdio: ['pipe', 'pipe', 'pipe'],
env: buildCodexProcessEnv(process.env),
});
const rl = createInterface({ input: proc.stdout! });
const server: AppServer = {
process: proc,
readline: rl,
pending: new Map(),
notificationHandlers: [],
exitHandlers: [],
serverRequestHandlers: [],
};
proc.stderr?.on('data', (chunk: Buffer) => {
const text = chunk.toString().trim();
if (text) log(`[stderr] ${text}`);
});
rl.on('line', (line: string) => {
if (!line.trim()) return;
let msg: JsonRpcMessage;
try {
msg = JSON.parse(line) as JsonRpcMessage;
} catch {
log(`[parse-error] ${line.slice(0, 200)}`);
return;
}
if (isResponse(msg)) {
const handler = server.pending.get(msg.id);
if (handler) {
server.pending.delete(msg.id);
handler.resolve(msg);
}
} else if (isServerRequest(msg)) {
for (const h of server.serverRequestHandlers) h(msg);
} else if ('method' in msg) {
for (const h of server.notificationHandlers) h(msg as JsonRpcNotification);
}
});
const failPending = (err: Error): void => {
for (const [, handler] of server.pending) handler.reject(err);
server.pending.clear();
};
proc.on('error', (err) => {
log(`[process-error] ${err.message}`);
failPending(err);
for (const h of [...server.exitHandlers]) h(err);
});
proc.on('exit', (code, signal) => {
log(`[exit] code=${code} signal=${signal}`);
const err = new Error(`Codex app-server exited: code=${code} signal=${signal}`);
failPending(err);
for (const h of [...server.exitHandlers]) h(err);
});
return server;
}
export function sendCodexRequest(
server: AppServer,
method: string,
params?: Record<string, unknown>,
timeoutMs = 60_000,
): Promise<JsonRpcResponse> {
const id = nextRequestId++;
const req = params === undefined ? { id, method } : { id, method, params };
const line = JSON.stringify(req) + '\n';
return new Promise<JsonRpcResponse>((resolve, reject) => {
const timer = setTimeout(() => {
server.pending.delete(id);
reject(new Error(`Timeout waiting for ${method} response (${timeoutMs}ms)`));
}, timeoutMs);
server.pending.set(id, {
resolve: (r) => {
clearTimeout(timer);
resolve(r);
},
reject: (e) => {
clearTimeout(timer);
reject(e);
},
});
try {
server.process.stdin!.write(line);
} catch (err) {
clearTimeout(timer);
server.pending.delete(id);
reject(err instanceof Error ? err : new Error(String(err)));
}
});
}
export function sendCodexNotification(server: AppServer, method: string, params?: Record<string, unknown>): void {
const line = JSON.stringify(params === undefined ? { method } : { method, params }) + '\n';
server.process.stdin!.write(line);
}
export function sendCodexResponse(server: AppServer, id: number | string, result: unknown): void {
try {
server.process.stdin!.write(JSON.stringify({ id, result }) + '\n');
} catch (err) {
log(`[send-error] response id=${id}: ${err instanceof Error ? err.message : String(err)}`);
}
}
export function killCodexAppServer(server: AppServer): void {
try {
server.readline.close();
server.process.kill('SIGTERM');
} catch {
/* ignore */
}
}
export async function initializeCodexAppServer(server: AppServer): Promise<void> {
const resp = await sendCodexRequest(
server,
'initialize',
{
clientInfo: { name: 'nanoclaw', title: 'NanoClaw', version: '2.0' },
capabilities: { experimentalApi: true },
},
INIT_TIMEOUT_MS,
);
if (resp.error) throw new Error(`initialize failed: ${resp.error.message}`);
sendCodexNotification(server, 'initialized');
}
export async function startOrResumeCodexThread(
server: AppServer,
threadId: string | undefined,
params: ThreadParams,
): Promise<string> {
const baseParams = {
model: params.model,
cwd: params.cwd,
approvalPolicy: CODEX_APPROVAL_POLICY,
sandbox: CODEX_SANDBOX_MODE,
baseInstructions: params.baseInstructions,
developerInstructions: params.developerInstructions,
personality: 'friendly',
sessionStartSource: 'startup',
persistExtendedHistory: false,
};
if (threadId) {
const resp = await sendCodexRequest(server, 'thread/resume', {
threadId,
...baseParams,
excludeTurns: true,
});
if (!resp.error) return threadId;
if (!STALE_THREAD_RE.test(resp.error.message)) {
throw new Error(`thread/resume failed: ${resp.error.message}`);
}
log(`Stale thread ${threadId}; starting fresh thread.`);
}
const resp = await sendCodexRequest(server, 'thread/start', {
...baseParams,
experimentalRawEvents: false,
});
if (resp.error) throw new Error(`thread/start failed: ${resp.error.message}`);
const result = resp.result as { thread?: { id?: string } } | undefined;
const newThreadId = result?.thread?.id;
if (!newThreadId) throw new Error('thread/start response missing thread ID');
return newThreadId;
}
export async function startCodexTurn(server: AppServer, params: TurnParams): Promise<string> {
const resp = await sendCodexRequest(server, 'turn/start', {
threadId: params.threadId,
input: [{ type: 'text', text: params.inputText, text_elements: [] }],
model: params.model,
effort: params.effort,
cwd: params.cwd,
});
if (resp.error) throw new Error(`turn/start failed: ${resp.error.message}`);
const result = resp.result as { turn?: { id?: string } } | undefined;
const turnId = result?.turn?.id;
if (!turnId) throw new Error('turn/start response missing turn ID');
return turnId;
}
export async function steerCodexTurn(
server: AppServer,
threadId: string,
turnId: string,
inputText: string,
): Promise<void> {
const resp = await sendCodexRequest(server, 'turn/steer', {
threadId,
expectedTurnId: turnId,
input: [{ type: 'text', text: inputText, text_elements: [] }],
});
if (resp.error) throw new Error(`turn/steer failed: ${resp.error.message}`);
}
export async function interruptCodexTurn(server: AppServer, threadId: string, turnId: string): Promise<void> {
const resp = await sendCodexRequest(server, 'turn/interrupt', { threadId, turnId }, 10_000);
if (resp.error) throw new Error(`turn/interrupt failed: ${resp.error.message}`);
}
// With approval_policy=never the command/patch approval requests don't fire, but the
// app-server still sends a few non-approval server→client requests (permission
// negotiation, MCP elicitations, tool calls) that must be answered or the turn hangs.
// NanoClaw is the boundary, so accept/grant everything.
export function attachCodexAutoApproval(server: AppServer): void {
server.serverRequestHandlers.push((req) => {
switch (req.method) {
case 'item/commandExecution/requestApproval':
case 'item/fileChange/requestApproval':
sendCodexResponse(server, req.id, { decision: 'accept' });
break;
case 'applyPatchApproval':
case 'execCommandApproval':
sendCodexResponse(server, req.id, { decision: 'approved' });
break;
case 'item/permissions/requestApproval':
sendCodexResponse(server, req.id, {
permissions: { fileSystem: { read: ['/'], write: ['/'] }, network: { enabled: true } },
scope: 'turn',
strictAutoReview: true,
});
break;
case 'item/tool/requestUserInput':
sendCodexResponse(server, req.id, { answers: {} });
break;
case 'mcpServer/elicitation/request':
sendCodexResponse(server, req.id, { action: 'cancel', content: null, _meta: null });
break;
case 'item/tool/call':
sendCodexResponse(server, req.id, { success: false, contentItems: [] });
break;
default:
sendCodexError(server, req.id, `Unhandled Codex app-server request: ${req.method}`);
break;
}
});
}
export function writeCodexConfigToml(
servers: Record<string, CodexMcpServer>,
opts: { model?: string; effort?: string } = {},
): void {
const codexConfigDir = path.join(process.env.HOME || '/home/node', '.codex');
fs.mkdirSync(codexConfigDir, { recursive: true });
const configTomlPath = path.join(codexConfigDir, 'config.toml');
// Instance-level defaults the app-server reads on startup; threads/turns inherit them.
const lines: string[] = [
`sandbox_mode = ${tomlBasicString(CODEX_SANDBOX_MODE)}`,
`approval_policy = ${tomlBasicString(CODEX_APPROVAL_POLICY)}`,
`project_doc_max_bytes = ${CODEX_PROJECT_DOC_MAX_BYTES}`,
];
if (opts.model) lines.push(`model = ${tomlBasicString(opts.model)}`);
if (opts.effort) lines.push(`model_reasoning_effort = ${tomlBasicString(opts.effort)}`);
lines.push('');
for (const [name, config] of Object.entries(servers)) {
lines.push(`[mcp_servers.${name}]`);
lines.push(`command = ${tomlBasicString(config.command)}`);
if (config.args && config.args.length > 0) {
lines.push(`args = [${config.args.map(tomlBasicString).join(', ')}]`);
}
if (config.env && Object.keys(config.env).length > 0) {
lines.push(`[mcp_servers.${name}.env]`);
for (const [key, value] of Object.entries(config.env)) {
lines.push(`${key} = ${tomlBasicString(value)}`);
}
}
lines.push('');
}
fs.writeFileSync(configTomlPath, lines.join('\n'));
}
export function buildCodexProcessEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const next: NodeJS.ProcessEnv = {};
for (const key of CODEX_ENV_ALLOWLIST) {
const value = env[key];
if (value !== undefined) next[key] = value;
}
if (!next.CODEX_HOME) next.CODEX_HOME = next.HOME ? path.join(next.HOME, '.codex') : '/home/node/.codex';
if (!next.HOME) next.HOME = '/home/node';
return next;
}
export function tomlBasicString(value: string): string {
if (value.includes('\n') || value.includes('\r')) {
throw new Error(`MCP config value contains newline: ${JSON.stringify(value.slice(0, 40))}`);
}
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
}
function sendCodexError(server: AppServer, id: number | string, message: string, data?: unknown): void {
try {
server.process.stdin!.write(JSON.stringify({ id, error: { code: -32000, message, data } }) + '\n');
} catch (err) {
log(`[send-error] error id=${id}: ${err instanceof Error ? err.message : String(err)}`);
}
}
function isResponse(msg: JsonRpcMessage): msg is JsonRpcResponse {
return 'id' in msg && ('result' in msg || 'error' in msg) && !('method' in msg);
}
function isServerRequest(msg: JsonRpcMessage): msg is JsonRpcServerRequest {
return 'id' in msg && 'method' in msg;
}
@@ -0,0 +1,39 @@
// Structural guard for the Codex CLI install in container/cli-tools.json.
//
// @openai/codex is a CLI *binary* installed from the global-CLI manifest (a
// json-merge seam), not an importable package, so the barrel-driven
// registration tests cannot see it. This test reads the real cli-tools.json
// and asserts the @openai/codex entry is present and pinned to an exact
// version. It goes red if the manifest entry is dropped or unpins.
//
// Runs under bun (same suite as the container registration test):
// cd container/agent-runner && bun test src/providers/codex-cli-tools.test.ts
import { existsSync, readFileSync } from 'fs';
import path from 'path';
import { describe, it, expect } from 'bun:test';
// container/agent-runner/src/providers/ -> container/cli-tools.json
const MANIFEST = path.join(import.meta.dir, '..', '..', '..', 'cli-tools.json');
const manifestPresent = existsSync(MANIFEST);
// Read lazily — `describe.skipIf` still runs the body to register tests, so the
// read has to be guarded for the bare-branch (no manifest) case.
const tools: Array<{ name: string; version: string }> = manifestPresent
? JSON.parse(readFileSync(MANIFEST, 'utf8'))
: [];
const codex = tools.find((t) => t.name === '@openai/codex');
// cli-tools.json is a trunk file; on the bare providers branch it isn't present,
// so skip there. In an installed tree (trunk + this payload) it must carry the
// pinned @openai/codex entry.
describe.skipIf(!manifestPresent)('container/cli-tools.json codex CLI install', () => {
it('includes the @openai/codex entry', () => {
expect(codex).toBeDefined();
});
it('pins it to an exact semver (no latest, no ranges)', () => {
expect(codex?.version).toMatch(/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/);
});
});
@@ -0,0 +1,22 @@
/**
* Integration test for the codex provider's CONTAINER-side reach-in: the self-registration
* import in container/agent-runner/src/providers/index.ts. Importing the barrel runs
* codex.ts's top-level registerProvider('codex', ); without that import line
* createProvider('codex') throws 'Unknown provider' at runtime.
*
* Behavior, not structural, and BARREL-ONLY: it imports the real barrel (./index.js),
* never ./codex.js directly, then asserts listProviderNames() contains the provider. The
* existing codex.factory.test.ts imports ./codex.js directly, so it self-registers and
* stays GREEN when the barrel line is deleted a unit test, not a registration guard.
* This goes red if the barrel import is deleted/drifts or the barrel fails to evaluate. codex uses the @openai/codex CLI *binary* (not an importable package), so this test does not guard that dependency the Dockerfile install line is guarded structurally + by the container build (see the skill validate step).
*/
import { describe, it, expect } from 'bun:test';
import { listProviderNames } from './provider-registry.js';
import './index.js'; // the real container provider barrel — triggers each provider's registerProvider()
describe('codex provider registration', () => {
it('registers codex via the provider barrel', () => {
expect(listProviderNames()).toContain('codex');
});
});
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'bun:test';
import { CodexProvider } from './codex.js';
describe('CodexProvider', () => {
it('rejects unsupported reasoning effort values', () => {
expect(() => new CodexProvider({ effort: 'max' })).toThrow(/Unsupported Codex reasoning effort/);
});
it('normalizes supported reasoning effort values', () => {
expect(new CodexProvider({ effort: 'HIGH' })).toBeInstanceOf(CodexProvider);
});
it('accepts supported reasoning effort values', () => {
expect(new CodexProvider({ effort: 'xhigh' })).toBeInstanceOf(CodexProvider);
});
});
@@ -0,0 +1,419 @@
import fs from 'fs';
import path from 'path';
import { registerProvider } from './provider-registry.js';
import type {
AgentProvider,
AgentQuery,
McpServerConfig,
ProviderEvent,
ProviderExchange,
ProviderOptions,
QueryInput,
} from './types.js';
import { archiveProviderExchange } from './exchange-archive.js';
import {
type AppServer,
type CodexReasoningEffort,
type JsonRpcNotification,
STALE_THREAD_RE,
attachCodexAutoApproval,
initializeCodexAppServer,
interruptCodexTurn,
killCodexAppServer,
spawnCodexAppServer,
startCodexTurn,
startOrResumeCodexThread,
steerCodexTurn,
writeCodexConfigToml,
} from './codex-app-server.js';
const TURN_TIMEOUT_MS = 10 * 60 * 1000;
const SUPPORTED_EFFORTS = new Set<CodexReasoningEffort>(['none', 'minimal', 'low', 'medium', 'high', 'xhigh']);
export interface CodexRuntimeDeps {
writeCodexConfigToml: typeof writeCodexConfigToml;
spawnCodexAppServer: typeof spawnCodexAppServer;
attachCodexAutoApproval: typeof attachCodexAutoApproval;
initializeCodexAppServer: typeof initializeCodexAppServer;
startOrResumeCodexThread: typeof startOrResumeCodexThread;
startCodexTurn: typeof startCodexTurn;
steerCodexTurn: typeof steerCodexTurn;
interruptCodexTurn: typeof interruptCodexTurn;
killCodexAppServer: typeof killCodexAppServer;
}
const defaultCodexRuntimeDeps: CodexRuntimeDeps = {
writeCodexConfigToml,
spawnCodexAppServer,
attachCodexAutoApproval,
initializeCodexAppServer,
startOrResumeCodexThread,
startCodexTurn,
steerCodexTurn,
interruptCodexTurn,
killCodexAppServer,
};
function classifyError(message: string): string | undefined {
if (/auth|api key|unauthorized|login|credential/i.test(message)) return 'auth';
if (/quota|rate limit|insufficient|billing|credit/i.test(message)) return 'quota';
if (/sandbox|permission|denied/i.test(message)) return 'sandbox';
if (/thread|conversation|session/i.test(message)) return 'stale-session';
return undefined;
}
function normalizeEffort(effort: string | undefined): CodexReasoningEffort | undefined {
const normalized = effort?.trim().toLowerCase();
if (!normalized) return undefined;
if (!SUPPORTED_EFFORTS.has(normalized as CodexReasoningEffort)) {
throw new Error(`Unsupported Codex reasoning effort: ${effort}`);
}
return normalized as CodexReasoningEffort;
}
export class CodexProvider implements AgentProvider {
readonly supportsNativeSlashCommands = false;
// Codex has no native NanoClaw memory — opt in to the runner's persistent
// memory/ scaffold (see memory-scaffold.ts).
readonly usesMemoryScaffold = true;
// The app-server keeps history server-side; there is no on-disk transcript,
// so the provider persists each exchange itself into `conversations/`
// (see exchange-archive.ts). The poll-loop reports exchanges through this
// hook and does nothing else — archiving is payload code, not runner code.
onExchangeComplete(exchange: ProviderExchange): void {
archiveProviderExchange({
provider: 'codex',
prompt: exchange.prompt,
result: exchange.result,
continuation: exchange.continuation,
status: exchange.status,
});
}
private readonly mcpServers: Record<string, McpServerConfig>;
private readonly model?: string;
private readonly effort?: CodexReasoningEffort;
private readonly runtime: CodexRuntimeDeps;
constructor(options: ProviderOptions = {}, runtime: CodexRuntimeDeps = defaultCodexRuntimeDeps) {
this.mcpServers = options.mcpServers ?? {};
this.model = options.model;
this.runtime = runtime;
this.effort = normalizeEffort(options.effort);
}
isSessionInvalid(err: unknown): boolean {
const msg = err instanceof Error ? err.message : String(err);
return STALE_THREAD_RE.test(msg);
}
query(input: QueryInput): AgentQuery {
const pending: string[] = [input.prompt];
let waiting: (() => void) | null = null;
let ended = false;
let aborted = false;
let activeServer: AppServer | null = null;
let activeThreadId: string | null = null;
let activeTurnId: string | null = null;
let wakeActiveTurn: (() => void) | null = null;
const wake = (): void => {
waiting?.();
waiting = null;
};
const pushOrSteer = (message: string): void => {
if (activeServer && activeThreadId && activeTurnId) {
void this.runtime.steerCodexTurn(activeServer, activeThreadId, activeTurnId, message).catch(() => {
pending.push(message);
wake();
});
return;
}
pending.push(message);
wake();
};
const self = this;
async function* gen(): AsyncGenerator<ProviderEvent> {
self.runtime.writeCodexConfigToml(self.mcpServers, { model: self.model, effort: self.effort });
const server = self.runtime.spawnCodexAppServer();
activeServer = server;
self.runtime.attachCodexAutoApproval(server);
let threadId: string | undefined = input.continuation;
let initYielded = false;
try {
await self.runtime.initializeCodexAppServer(server);
threadId = await self.runtime.startOrResumeCodexThread(server, threadId, {
model: self.model,
cwd: input.cwd,
baseInstructions: input.systemContext?.instructions,
});
activeThreadId = threadId;
while (!aborted) {
while (pending.length === 0 && !ended && !aborted) {
await new Promise<void>((resolve) => {
waiting = resolve;
});
}
if (aborted) return;
if (pending.length === 0 && ended) return;
const text = pending.shift()!;
yield* runOneTurn(
server,
threadId,
text,
self.model,
self.effort,
input.cwd,
(turnId) => {
activeTurnId = turnId;
},
() => {
activeTurnId = null;
},
() => initYielded,
() => {
initYielded = true;
},
() => aborted,
(waker) => {
wakeActiveTurn = waker;
},
self.runtime.startCodexTurn,
);
}
} finally {
activeTurnId = null;
activeThreadId = null;
activeServer = null;
wakeActiveTurn = null;
self.runtime.killCodexAppServer(server);
}
}
return {
push: pushOrSteer,
end: () => {
ended = true;
wake();
},
abort: () => {
aborted = true;
if (activeServer && activeThreadId && activeTurnId) {
void this.runtime.interruptCodexTurn(activeServer, activeThreadId, activeTurnId).catch(() => {});
}
wakeActiveTurn?.();
wake();
},
events: gen(),
};
}
}
async function* runOneTurn(
server: AppServer,
threadId: string,
inputText: string,
model: string | undefined,
effort: string | undefined,
cwd: string,
setActiveTurn: (turnId: string) => void,
clearActiveTurn: () => void,
hasInit: () => boolean,
markInit: () => void,
isAborted: () => boolean,
setAbortWaker: (waker: (() => void) | null) => void,
startTurn: typeof startCodexTurn,
): AsyncGenerator<ProviderEvent> {
const state: { error: Error | null } = { error: null };
let resultText = '';
let turnDone = false;
let turnId: string | null = null;
// A finished turn can no longer absorb steered input: codex's turn/steer
// against a completed turn resolves as a no-op, so a follow-up routed there
// is lost silently. Clear the active-turn marker the moment the turn ends —
// before the generator drains and tears down in its `finally` — so
// pushOrSteer queues any racing follow-up into a fresh turn instead.
const finishTurn = (): void => {
turnDone = true;
clearActiveTurn();
};
const buffer: ProviderEvent[] = [];
let waker: (() => void) | null = null;
const kick = (): void => {
waker?.();
waker = null;
};
setAbortWaker(kick);
const handler = (n: JsonRpcNotification): void => {
const method = n.method;
const params = n.params ?? {};
buffer.push({ type: 'activity' });
switch (method) {
case 'thread/started': {
const thread = params.thread as { id?: string } | undefined;
if (thread?.id && !hasInit()) {
markInit();
buffer.push({ type: 'init', continuation: thread.id });
}
break;
}
case 'turn/started': {
const turn = params.turn as { id?: string } | undefined;
if (turn?.id) {
turnId = turn.id;
setActiveTurn(turn.id);
}
break;
}
case 'item/agentMessage/delta': {
const delta = params.delta as string | undefined;
if (delta) resultText += delta;
break;
}
case 'item/completed': {
const item = params.item as { type?: string; text?: string } | undefined;
if (item?.type === 'agentMessage' && item.text) resultText = item.text;
break;
}
case 'thread/status/changed': {
const status = params.status as string | undefined;
if (status) buffer.push({ type: 'progress', message: `status: ${status}` });
break;
}
case 'error': {
const err = params.error as { message?: string; additionalDetails?: string | null } | undefined;
const msg = [err?.message, err?.additionalDetails].filter(Boolean).join(': ') || 'Codex turn failed';
state.error = new Error(msg);
finishTurn();
break;
}
case 'turn/completed': {
const turn = params.turn as
| { error?: { message?: string; additionalDetails?: string | null } | null; items?: unknown[] }
| undefined;
const agentMessage = turn?.items
?.filter((item): item is { type: string; text?: string } => typeof item === 'object' && item !== null)
.find((item) => item.type === 'agentMessage' && item.text);
if (agentMessage?.text) resultText = agentMessage.text;
if (turn?.error) {
const msg =
[turn.error.message, turn.error.additionalDetails].filter(Boolean).join(': ') || 'Codex turn failed';
state.error = new Error(msg);
}
finishTurn();
break;
}
default:
break;
}
kick();
};
server.notificationHandlers.push(handler);
// A dead app-server can't send the notification this turn is parked on —
// end the turn immediately with the real cause instead of the 10-min timeout.
const onServerExit = (err: Error): void => {
if (turnDone) return;
state.error = err;
finishTurn();
kick();
};
server.exitHandlers.push(onServerExit);
const timer = setTimeout(() => {
state.error = new Error(`Turn timed out after ${TURN_TIMEOUT_MS}ms`);
finishTurn();
kick();
}, TURN_TIMEOUT_MS);
try {
if (!hasInit()) {
markInit();
buffer.push({ type: 'init', continuation: threadId });
}
turnId = await startTurn(server, {
threadId,
inputText,
model,
effort,
cwd,
});
setActiveTurn(turnId);
const imagesBefore = listGeneratedImages(threadId);
if (isAborted()) return;
while (true) {
while (buffer.length > 0) {
yield buffer.shift()!;
}
if (turnDone || isAborted()) break;
await new Promise<void>((resolve) => {
waker = resolve;
});
waker = null;
}
while (buffer.length > 0) yield buffer.shift()!;
if (isAborted()) return;
if (state.error) {
yield {
type: 'error',
message: state.error.message,
retryable: false,
classification: classifyError(state.error.message),
};
throw state.error;
}
for (const imagePath of listGeneratedImages(threadId)) {
if (!imagesBefore.has(imagePath)) {
yield { type: 'file', path: imagePath };
}
}
yield { type: 'result', text: resultText || null };
} finally {
clearTimeout(timer);
clearActiveTurn();
setAbortWaker(null);
const idx = server.notificationHandlers.indexOf(handler);
if (idx >= 0) server.notificationHandlers.splice(idx, 1);
const exitIdx = server.exitHandlers.indexOf(onServerExit);
if (exitIdx >= 0) server.exitHandlers.splice(exitIdx, 1);
}
}
/**
* Codex's built-in image generation saves into CODEX_HOME/generated_images/
* <threadId>/ its native client renders those to the user, so the model
* believes delivery already happened and won't send_file them. The runner
* must deliver them itself: snapshot the dir at turn start, emit a `file`
* event for anything new at turn end.
*/
function listGeneratedImages(threadId: string): Set<string> {
const dir = path.join(process.env.CODEX_HOME || '/home/node/.codex', 'generated_images', threadId);
try {
return new Set(fs.readdirSync(dir).map((f) => path.join(dir, f)));
} catch {
return new Set();
}
}
registerProvider('codex', (opts) => new CodexProvider(opts));
@@ -0,0 +1,267 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { CodexProvider, type CodexRuntimeDeps } from './codex.js';
import type { AppServer, JsonRpcNotification, TurnParams } from './codex-app-server.js';
import type { ProviderEvent } from './types.js';
describe('CodexProvider active turns', () => {
it('steers follow-ups into the active turn and yields liveness activity', async () => {
const fake = createFakeCodexRuntime();
const provider = new CodexProvider({}, fake.runtime);
const query = provider.query({ prompt: 'first prompt', cwd: '/workspace/agent' });
const events: ProviderEvent[] = [];
const collect = collectEvents(query.events, events);
await waitFor(() => fake.startCalls.length === 1);
query.push('follow-up prompt');
await waitFor(() => fake.steerCalls.length === 1);
query.end();
fake.completeTurn('final answer');
await collect;
expect(fake.startCalls).toHaveLength(1);
expect(fake.startCalls[0].inputText).toBe('first prompt');
expect(fake.steerCalls).toEqual([{ threadId: 'thread-1', turnId: 'turn-1', inputText: 'follow-up prompt' }]);
expect(events.filter((event) => event.type === 'activity').length).toBeGreaterThanOrEqual(2);
expect(events.filter((event) => event.type === 'result')).toEqual([{ type: 'result', text: 'final answer' }]);
expect(fake.killed).toBe(true);
});
it('queues follow-ups for the next turn when steering is rejected', async () => {
const fake = createFakeCodexRuntime({ rejectSteer: true });
const provider = new CodexProvider({}, fake.runtime);
const query = provider.query({ prompt: 'first prompt', cwd: '/workspace/agent' });
const events: ProviderEvent[] = [];
const collect = collectEvents(query.events, events);
await waitFor(() => fake.startCalls.length === 1);
query.push('queued follow-up');
await waitFor(() => fake.steerCalls.length === 1);
await sleep(0);
fake.completeTurn('first answer');
await waitFor(() => fake.startCalls.length === 2);
query.end();
fake.completeTurn('second answer');
await collect;
expect(fake.startCalls.map((call) => call.inputText)).toEqual(['first prompt', 'queued follow-up']);
expect(fake.steerCalls).toHaveLength(1);
expect(events.filter((event) => event.type === 'result')).toEqual([
{ type: 'result', text: 'first answer' },
{ type: 'result', text: 'second answer' },
]);
});
it('queues a follow-up that races turn completion into a new turn, never steering the finished turn', async () => {
const fake = createFakeCodexRuntime();
const provider = new CodexProvider({}, fake.runtime);
const query = provider.query({ prompt: 'first prompt', cwd: '/workspace/agent' });
const events: ProviderEvent[] = [];
const collect = collectEvents(query.events, events);
await waitFor(() => fake.startCalls.length === 1);
// The turn completes, then a follow-up lands in the same tick — before the
// generator has drained and torn the turn down. codex's turn/steer no-ops
// on a finished turn (resolves without error), so steering here would drop
// the message silently. It must start a fresh turn instead.
fake.completeTurn('first answer');
query.push('racing follow-up');
await waitFor(() => fake.startCalls.length === 2);
query.end();
fake.completeTurn('second answer');
await collect;
expect(fake.steerCalls).toHaveLength(0);
expect(fake.startCalls.map((call) => call.inputText)).toEqual(['first prompt', 'racing follow-up']);
expect(events.filter((event) => event.type === 'result')).toEqual([
{ type: 'result', text: 'first answer' },
{ type: 'result', text: 'second answer' },
]);
});
it('interrupts the active turn and closes the stream on abort', async () => {
const fake = createFakeCodexRuntime();
const provider = new CodexProvider({}, fake.runtime);
const query = provider.query({ prompt: 'first prompt', cwd: '/workspace/agent' });
const events: ProviderEvent[] = [];
const collect = collectEvents(query.events, events);
await waitFor(() => fake.startCalls.length === 1);
query.abort();
await collect;
expect(fake.interruptCalls).toEqual([{ threadId: 'thread-1', turnId: 'turn-1' }]);
expect(events.some((event) => event.type === 'result')).toBe(false);
expect(fake.killed).toBe(true);
});
it('threads the configured model and effort into the turn', async () => {
const fake = createFakeCodexRuntime();
const provider = new CodexProvider({ model: 'gpt-5.5', effort: 'high' }, fake.runtime);
const query = provider.query({ prompt: 'first prompt', cwd: '/workspace/agent' });
const events: ProviderEvent[] = [];
const collect = collectEvents(query.events, events);
await waitFor(() => fake.startCalls.length === 1);
query.end();
fake.completeTurn('final answer');
await collect;
expect(fake.startCalls[0].model).toBe('gpt-5.5');
expect(fake.startCalls[0].effort).toBe('high');
expect(events.filter((event) => event.type === 'result')).toEqual([{ type: 'result', text: 'final answer' }]);
});
it('delivers harness-generated images as file events — the model never sends them itself', async () => {
const codexHome = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-home-'));
const prevHome = process.env.CODEX_HOME;
process.env.CODEX_HOME = codexHome;
try {
const fake = createFakeCodexRuntime();
const provider = new CodexProvider({}, fake.runtime);
const query = provider.query({ prompt: 'make an image', cwd: '/workspace/agent' });
const events: ProviderEvent[] = [];
const collect = collectEvents(query.events, events);
await waitFor(() => fake.startCalls.length === 1);
// Codex's built-in image_gen writes into CODEX_HOME mid-turn.
const imagesDir = path.join(codexHome, 'generated_images', 'thread-1');
fs.mkdirSync(imagesDir, { recursive: true });
fs.writeFileSync(path.join(imagesDir, 'ig_abc.png'), 'png-bytes');
query.end();
fake.completeTurn('Here you go — created the image.');
await collect;
const files = events.filter((event) => event.type === 'file') as Array<{ type: 'file'; path: string }>;
expect(files).toHaveLength(1);
expect(files[0].path).toBe(path.join(imagesDir, 'ig_abc.png'));
// file events arrive before the result so delivery shares the turn.
expect(events.findIndex((e) => e.type === 'file')).toBeLessThan(events.findIndex((e) => e.type === 'result'));
} finally {
if (prevHome === undefined) delete process.env.CODEX_HOME;
else process.env.CODEX_HOME = prevHome;
fs.rmSync(codexHome, { recursive: true, force: true });
}
});
it('ends the turn immediately with the real cause when the app-server dies mid-turn', async () => {
const fake = createFakeCodexRuntime();
const provider = new CodexProvider({}, fake.runtime);
const query = provider.query({ prompt: 'prompt', cwd: '/workspace/agent' });
const events: ProviderEvent[] = [];
const collect = collectEvents(query.events, events);
await waitFor(() => fake.startCalls.length === 1);
// No pending request exists mid-turn (turn/start already resolved), so
// only the exitHandlers seam can end the turn — without it this parks
// on the waker until the 10-minute turn timeout.
fake.crashServer(new Error('Codex app-server exited: code=1 signal=null'));
// The generator yields the error event, then rethrows to its consumer.
await collect.catch(() => {});
const errors = events.filter((event) => event.type === 'error');
expect(errors).toHaveLength(1);
expect((errors[0] as { message: string }).message).toContain('app-server exited');
});
});
function createFakeCodexRuntime(opts: { rejectSteer?: boolean } = {}) {
const server = fakeServer();
const startCalls: TurnParams[] = [];
const steerCalls: Array<{ threadId: string; turnId: string; inputText: string }> = [];
const interruptCalls: Array<{ threadId: string; turnId: string }> = [];
let killed = false;
const notify = (method: string, params?: Record<string, unknown>): void => {
const notification: JsonRpcNotification = { method, params };
for (const handler of [...server.notificationHandlers]) handler(notification);
};
const runtime: CodexRuntimeDeps = {
writeCodexConfigToml: () => {},
spawnCodexAppServer: () => server,
attachCodexAutoApproval: () => {},
initializeCodexAppServer: async () => {},
startOrResumeCodexThread: async (_server, threadId) => threadId ?? 'thread-1',
startCodexTurn: async (_server, params) => {
startCalls.push(params);
const turnId = `turn-${startCalls.length}`;
notify('turn/started', { turn: { id: turnId } });
return turnId;
},
steerCodexTurn: async (_server, threadId, turnId, inputText) => {
steerCalls.push({ threadId, turnId, inputText });
if (opts.rejectSteer) throw new Error('steer rejected');
},
interruptCodexTurn: async (_server, threadId, turnId) => {
interruptCalls.push({ threadId, turnId });
},
killCodexAppServer: () => {
killed = true;
},
};
return {
runtime,
startCalls,
steerCalls,
interruptCalls,
get killed() {
return killed;
},
completeTurn(text: string) {
notify('turn/completed', { turn: { items: [{ type: 'agentMessage', text }] } });
},
crashServer(err: Error) {
for (const h of [...server.exitHandlers]) h(err);
},
};
}
function fakeServer(): AppServer {
return {
process: { stdin: { write: () => true }, kill: () => true },
readline: { close: () => {} },
pending: new Map(),
notificationHandlers: [],
exitHandlers: [],
serverRequestHandlers: [],
} as unknown as AppServer;
}
async function collectEvents(events: AsyncIterable<ProviderEvent>, sink: ProviderEvent[]): Promise<void> {
for await (const event of events) {
sink.push(event);
}
}
async function waitFor(condition: () => boolean, timeoutMs = 1000): Promise<void> {
const start = Date.now();
while (!condition()) {
if (Date.now() - start > timeoutMs) throw new Error('waitFor timeout');
await sleep(10);
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
@@ -0,0 +1,136 @@
import { afterEach, describe, expect, it } from 'bun:test';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { archiveProviderExchange } from './exchange-archive.js';
let tmpDir: string | null = null;
afterEach(() => {
if (tmpDir) {
fs.rmSync(tmpDir, { recursive: true, force: true });
tmpDir = null;
}
});
function makeTmpDir(): string {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-archive-'));
return tmpDir;
}
describe('provider exchange archive', () => {
it('appends same-thread exchanges into one file with a single header', () => {
const conversationsDir = makeTmpDir();
const timestamp = new Date('2026-06-03T12:34:56.789Z');
const first = archiveProviderExchange({
conversationsDir,
provider: 'codex',
prompt: 'hello',
result: 'world',
continuation: 'thread-123',
status: 'completed',
timestamp,
});
const second = archiveProviderExchange({
conversationsDir,
provider: 'codex',
prompt: 'hello again',
result: 'world again',
continuation: 'thread-123',
status: 'completed',
timestamp,
});
// Same thread → same date-prefixed, thread-stable file, not one per exchange.
expect(first).toBe('2026-06-03-codex-thread-123.md');
expect(second).toBe(first);
expect(fs.readdirSync(conversationsDir)).toHaveLength(1);
const content = fs.readFileSync(path.join(conversationsDir, first!), 'utf-8');
// Header (thread-level metadata) written exactly once.
expect(content.match(/# Codex Conversation/g)).toHaveLength(1);
expect(content).toContain('Provider: codex');
expect(content).toContain('Continuation/thread id: thread-123');
// Both exchanges present, each with its own status line.
expect(content).toContain('**User**: hello');
expect(content).toContain('**Assistant**: world');
expect(content).toContain('**User**: hello again');
expect(content).toContain('**Assistant**: world again');
expect(content.match(/Status: completed/g)).toHaveLength(2);
});
it('writes a separate file per thread', () => {
const conversationsDir = makeTmpDir();
const timestamp = new Date('2026-06-03T12:34:56.789Z');
const a = archiveProviderExchange({
conversationsDir,
provider: 'codex',
prompt: 'p',
result: 'r',
continuation: 'thread-a',
status: 'completed',
timestamp,
});
const b = archiveProviderExchange({
conversationsDir,
provider: 'codex',
prompt: 'p',
result: 'r',
continuation: 'thread-b',
status: 'completed',
timestamp,
});
expect(a).toBe('2026-06-03-codex-thread-a.md');
expect(b).toBe('2026-06-03-codex-thread-b.md');
expect(fs.readdirSync(conversationsDir)).toHaveLength(2);
});
it('keeps the creation-date prefix stable when later exchanges land on another day', () => {
const conversationsDir = makeTmpDir();
const first = archiveProviderExchange({
conversationsDir,
provider: 'codex',
prompt: 'a',
result: 'b',
continuation: 'thread-x',
status: 'completed',
timestamp: new Date('2026-06-03T10:00:00.000Z'),
});
// A later exchange on a different day must append to the same file, not
// mint a new 2026-06-05-* one (the bug a naive date-from-timestamp scheme
// would introduce).
const second = archiveProviderExchange({
conversationsDir,
provider: 'codex',
prompt: 'c',
result: 'd',
continuation: 'thread-x',
status: 'completed',
timestamp: new Date('2026-06-05T10:00:00.000Z'),
});
expect(first).toBe('2026-06-03-codex-thread-x.md');
expect(second).toBe(first);
expect(fs.readdirSync(conversationsDir)).toHaveLength(1);
});
it('skips empty result text', () => {
const conversationsDir = makeTmpDir();
const filename = archiveProviderExchange({
conversationsDir,
provider: 'codex',
prompt: 'hello',
result: ' ',
continuation: 'thread-123',
status: 'completed',
});
expect(filename).toBeNull();
expect(fs.readdirSync(conversationsDir)).toHaveLength(0);
});
});
@@ -0,0 +1,110 @@
import fs from 'fs';
import path from 'path';
import { TIMEZONE, formatLocalStamp } from '../timezone.js';
/**
* Per-thread conversation archive for providers with no on-disk transcript
* payload code, shipped with the provider that needs it. The provider's
* `onExchangeComplete` hook (see types.ts) calls this with each completed
* exchange; the runner never archives on a provider's behalf.
*
* One file per thread (keyed on the continuation id), named
* `<date>-<provider>-<thread>.md` and appended to as exchanges complete
* mirroring the Claude path's one-file-per-session granularity and its
* date-prefixed, name-sortable filenames, since the Codex app-server keeps
* history server-side with no transcript to roll up at a compaction boundary.
* The date is the thread's creation day and stays stable across later appends.
*/
const DEFAULT_CONVERSATIONS_DIR = '/workspace/agent/conversations';
export interface ProviderExchangeArchiveOptions {
provider: string;
prompt: string;
result: string | null | undefined;
continuation?: string;
status: string;
timestamp?: Date;
conversationsDir?: string;
}
/**
* Append a single prompt/result exchange to its thread's conversation file,
* writing the thread-level header once when the file is first created. Returns
* the (thread-stable) filename, or null when there is nothing to archive
* (empty result).
*/
export function archiveProviderExchange(options: ProviderExchangeArchiveOptions): string | null {
const result = options.result?.trim();
if (!result) return null;
const timestamp = options.timestamp ?? new Date();
const conversationsDir =
options.conversationsDir || process.env.NANOCLAW_CONVERSATIONS_DIR || DEFAULT_CONVERSATIONS_DIR;
fs.mkdirSync(conversationsDir, { recursive: true });
const filename = threadArchiveFilename(conversationsDir, options.provider, options.continuation, timestamp);
const filePath = path.join(conversationsDir, filename);
// Thread-level metadata (provider, thread id) belongs in the header, written
// once. Per-exchange metadata (timestamp, status) rides in each appended
// block. Each block leads with a blank line + `---` so the separator renders
// as a thematic break, not a setext heading underline on the prior line.
const parts: string[] = [];
if (!fs.existsSync(filePath)) {
parts.push(
`# ${titleCase(options.provider)} Conversation`,
'',
`Provider: ${options.provider}`,
`Continuation/thread id: ${options.continuation || '(none)'}`,
);
}
parts.push(
'',
'---',
'',
`Archived: ${formatLocalStamp(timestamp, TIMEZONE)} · Status: ${options.status}`,
'',
`**User**: ${truncate(options.prompt)}`,
'',
`**Assistant**: ${truncate(result)}`,
'',
);
fs.appendFileSync(filePath, parts.join('\n'));
return filename;
}
function threadArchiveFilename(
dir: string,
provider: string,
continuation: string | undefined,
timestamp: Date,
): string {
const thread = sanitizeSlug(continuation || 'no-thread').slice(0, 48) || 'no-thread';
const suffix = `${sanitizeSlug(provider)}-${thread}.md`;
// Reuse this thread's existing file whatever day it was created; only stamp a
// new date when none exists. Match on the suffix after the date prefix.
const dated = /^\d{4}-\d{2}-\d{2}-/;
const existing = fs.readdirSync(dir).find((f) => dated.test(f) && f.replace(dated, '') === suffix);
if (existing) return existing;
// Local calendar day — the agent navigates conversations/ by these
// date-sortable names, and evening sessions west of UTC would otherwise
// land under tomorrow's date.
return `${formatLocalStamp(timestamp, TIMEZONE).slice(0, 10)}-${suffix}`;
}
function sanitizeSlug(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
function titleCase(value: string): string {
return value ? value[0].toUpperCase() + value.slice(1) : 'Provider';
}
function truncate(value: string): string {
return value.length > 2000 ? value.slice(0, 2000) + '...' : value;
}
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'bun:test';
import { createProvider, type ProviderName } from './factory.js';
import { ClaudeProvider } from './claude.js';
import { CodexProvider } from './codex.js';
import { MockProvider } from './mock.js';
describe('createProvider', () => {
@@ -9,6 +10,10 @@ describe('createProvider', () => {
expect(createProvider('claude')).toBeInstanceOf(ClaudeProvider);
});
it('returns CodexProvider for codex', () => {
expect(createProvider('codex')).toBeInstanceOf(CodexProvider);
});
it('returns MockProvider for mock', () => {
expect(createProvider('mock')).toBeInstanceOf(MockProvider);
});
@@ -3,4 +3,6 @@
// level. Skills add a new provider by appending one import line below.
import './claude.js';
import './codex.js';
import './mock.js';
import './opencode.js';
@@ -0,0 +1,59 @@
import { describe, it, expect } from 'bun:test';
import { mcpServersToOpenCodeConfig } from './mcp-to-opencode.js';
describe('mcpServersToOpenCodeConfig', () => {
it('maps nanoclaw + extra server like v2 index.ts merge', () => {
const servers = {
nanoclaw: {
command: 'node',
args: ['/app/src/mcp-tools/index.js'],
env: {
SESSION_INBOUND_DB_PATH: '/workspace/inbound.db',
SESSION_OUTBOUND_DB_PATH: '/workspace/outbound.db',
SESSION_HEARTBEAT_PATH: '/workspace/.heartbeat',
},
},
extra: {
command: 'npx',
args: ['-y', 'some-mcp'],
env: { FOO: 'bar' },
},
};
const mcp = mcpServersToOpenCodeConfig(servers);
expect(mcp.nanoclaw).toEqual({
type: 'local',
command: ['node', '/app/src/mcp-tools/index.js'],
environment: {
SESSION_INBOUND_DB_PATH: '/workspace/inbound.db',
SESSION_OUTBOUND_DB_PATH: '/workspace/outbound.db',
SESSION_HEARTBEAT_PATH: '/workspace/.heartbeat',
},
enabled: true,
});
expect(mcp.extra).toEqual({
type: 'local',
command: ['npx', '-y', 'some-mcp'],
environment: { FOO: 'bar' },
enabled: true,
});
});
it('omits environment when env is empty', () => {
const mcp = mcpServersToOpenCodeConfig({
x: { command: 'true', args: [], env: {} },
});
expect(mcp.x).toEqual({
type: 'local',
command: ['true'],
enabled: true,
});
});
it('returns empty record for undefined', () => {
expect(mcpServersToOpenCodeConfig(undefined)).toEqual({});
});
});
@@ -0,0 +1,39 @@
import type { McpServerConfig } from './types.js';
/** OpenCode `mcp` entry shape (local stdio server). */
export type OpenCodeMcpLocal = {
type: 'local';
command: string[];
environment?: Record<string, string>;
enabled: true;
};
/** OpenCode `mcp` entry shape (remote HTTP server). */
export type OpenCodeMcpRemote = {
type: 'remote';
url: string;
headers?: Record<string, string>;
enabled: true;
};
export type OpenCodeMcpEntry = OpenCodeMcpLocal | OpenCodeMcpRemote;
/**
* Map NanoClaw v2 MCP definitions (same shape as Claude Agent SDK) into
* OpenCode config `mcp` field. Stdio-only until `McpServerConfig` gains remote.
*/
export function mcpServersToOpenCodeConfig(
servers: Record<string, McpServerConfig> | undefined,
): Record<string, OpenCodeMcpEntry> {
const out: Record<string, OpenCodeMcpEntry> = {};
if (!servers) return out;
for (const [name, cfg] of Object.entries(servers)) {
out[name] = {
type: 'local',
command: [cfg.command, ...cfg.args],
...(Object.keys(cfg.env).length > 0 ? { environment: cfg.env } : {}),
enabled: true,
};
}
return out;
}
@@ -0,0 +1,22 @@
/**
* Integration test for the opencode provider's CONTAINER-side reach-in: the self-registration
* import in container/agent-runner/src/providers/index.ts. Importing the barrel runs
* opencode.ts's top-level registerProvider('opencode', ); without that import line
* createProvider('opencode') throws 'Unknown provider' at runtime.
*
* Behavior, not structural, and BARREL-ONLY: it imports the real barrel (./index.js),
* never ./opencode.js directly, then asserts listProviderNames() contains the provider. The
* existing opencode.factory.test.ts imports ./opencode.js directly, so it self-registers and
* stays GREEN when the barrel line is deleted a unit test, not a registration guard.
* This goes red if the barrel import is deleted/drifts or the barrel fails to evaluate, or if @opencode-ai/sdk is not installed (the unmocked barrel import throws) so it also implicitly guards that dependency.
*/
import { describe, it, expect } from 'bun:test';
import { listProviderNames } from './provider-registry.js';
import './index.js'; // the real container provider barrel — triggers each provider's registerProvider()
describe('opencode provider registration', () => {
it('registers opencode via the provider barrel', () => {
expect(listProviderNames()).toContain('opencode');
});
});
@@ -0,0 +1,10 @@
import { describe, it, expect } from 'bun:test';
import { createProvider } from './factory.js';
import { OpenCodeProvider } from './opencode.js';
describe('createProvider (opencode)', () => {
it('returns OpenCodeProvider for opencode', () => {
expect(createProvider('opencode')).toBeInstanceOf(OpenCodeProvider);
});
});
@@ -0,0 +1,423 @@
import { spawn, type ChildProcess } from 'child_process';
import { createOpencodeClient, type OpencodeClient } from '@opencode-ai/sdk';
import { registerProvider } from './provider-registry.js';
import type { AgentProvider, AgentQuery, ProviderEvent, ProviderOptions, QueryInput } from './types.js';
import { mcpServersToOpenCodeConfig } from './mcp-to-opencode.js';
function log(msg: string): void {
console.error(`[opencode-provider] ${msg}`);
}
const SESSION_STATUS_RETRY_ERROR_AFTER = 3;
/** Stale / dead OpenCode session heuristics (complement Claude-centric host patterns). */
const STALE_SESSION_RE =
/no conversation found|ENOENT.*\.jsonl|session.*not found|NotFoundError|connection reset|ECONNRESET|404|event timeout/i;
function killProcessTree(proc: ChildProcess): void {
if (!proc.pid) return;
try {
process.kill(-proc.pid, 'SIGKILL');
} catch {
try {
proc.kill('SIGKILL');
} catch {
/* ignore */
}
}
}
function spawnOpencodeServer(config: Record<string, unknown>, timeoutMs = 10_000): Promise<{ url: string; proc: ChildProcess }> {
return new Promise((resolve, reject) => {
const hostname = '127.0.0.1';
const port = 4096;
const proc = spawn('opencode', ['serve', `--hostname=${hostname}`, `--port=${port}`], {
env: {
...process.env,
OPENCODE_CONFIG_CONTENT: JSON.stringify(config),
},
detached: true,
});
const id = setTimeout(() => {
killProcessTree(proc);
reject(new Error(`Timeout waiting for OpenCode server to start after ${timeoutMs}ms`));
}, timeoutMs);
let output = '';
proc.stdout?.on('data', (chunk: Buffer) => {
output += chunk.toString();
for (const line of output.split('\n')) {
if (line.startsWith('opencode server listening')) {
const match = line.match(/on\s+(https?:\/\/[^\s]+)/);
if (match) {
clearTimeout(id);
resolve({ url: match[1], proc });
}
}
}
});
proc.stderr?.on('data', (chunk: Buffer) => {
output += chunk.toString();
});
proc.on('exit', (code) => {
clearTimeout(id);
let msg = `OpenCode server exited with code ${code}`;
if (output.trim()) msg += `\nServer output: ${output}`;
reject(new Error(msg));
});
proc.on('error', (err) => {
clearTimeout(id);
reject(err);
});
});
}
function wrapPromptWithContext(text: string, systemInstructions?: string): string {
let out = text;
if (systemInstructions) {
out = `<system>\n${systemInstructions}\n</system>\n\n${out}`;
}
return out;
}
function buildOpenCodeConfig(options: ProviderOptions): Record<string, unknown> {
const provider = process.env.OPENCODE_PROVIDER || 'anthropic';
const model = process.env.OPENCODE_MODEL;
const smallModel = process.env.OPENCODE_SMALL_MODEL;
const proxyUrl = process.env.ANTHROPIC_BASE_URL;
const providerModelId = model ? model.replace(new RegExp(`^${provider}/`), '') : undefined;
const providerSmallModelId = smallModel ? smallModel.replace(new RegExp(`^${provider}/`), '') : undefined;
const modelsToRegister = [providerModelId, providerSmallModelId]
.filter(Boolean)
.filter((mid, i, a) => a.indexOf(mid as string) === i);
const providerOptions: Record<string, unknown> =
provider === 'anthropic'
? {}
: {
[provider]: {
options: { apiKey: 'placeholder', baseURL: proxyUrl },
...(modelsToRegister.length > 0
? {
models: Object.fromEntries(
modelsToRegister.map((mid) => [mid, { id: mid, name: mid, tool_call: true }]),
),
}
: {}),
},
};
const mcp = mcpServersToOpenCodeConfig(options.mcpServers);
// Load shared base + per-group fragments + per-group memory through OpenCode's
// native instructions pipeline (session/instruction.ts). Absolute paths with
// globs are supported. Files are read raw — `@./...` includes are NOT expanded
// by OpenCode, so point at the concrete files, not at composed CLAUDE.md.
const instructions = [
'/app/CLAUDE.md',
'/workspace/agent/.claude-fragments/*.md',
'/workspace/agent/CLAUDE.local.md',
];
return {
...(model ? { model } : {}),
...(smallModel ? { small_model: smallModel } : {}),
enabled_providers: [provider],
permission: 'allow',
autoupdate: false,
snapshot: false,
provider: providerOptions,
instructions,
mcp,
};
}
type SharedRuntime = {
proc: ChildProcess;
client: OpencodeClient;
stream: AsyncGenerator<{ type: string; properties: Record<string, unknown> }, void, void>;
streamRelease: () => void;
};
let sharedRuntime: SharedRuntime | null = null;
let sharedConfigKey: string | null = null;
let sharedInit: Promise<SharedRuntime> | null = null;
function runtimeConfigKey(options: ProviderOptions): string {
return JSON.stringify({
mcp: mcpServersToOpenCodeConfig(options.mcpServers),
model: process.env.OPENCODE_MODEL,
small: process.env.OPENCODE_SMALL_MODEL,
op: process.env.OPENCODE_PROVIDER,
});
}
async function ensureSharedRuntime(options: ProviderOptions): Promise<SharedRuntime> {
const key = runtimeConfigKey(options);
if (sharedRuntime && sharedConfigKey === key) return sharedRuntime;
if (sharedInit) return sharedInit;
sharedInit = (async () => {
if (sharedRuntime) {
destroySharedRuntime();
}
const config = buildOpenCodeConfig(options);
const { url, proc } = await spawnOpencodeServer(config);
const client = createOpencodeClient({ baseUrl: url });
const sub = await client.event.subscribe();
const stream = sub.stream as AsyncGenerator<{ type: string; properties: Record<string, unknown> }, void, void>;
sharedRuntime = {
proc,
client,
stream,
streamRelease: () => {
void stream.return?.(undefined);
},
};
sharedConfigKey = key;
sharedInit = null;
return sharedRuntime;
})();
return sharedInit;
}
export function destroySharedRuntime(): void {
if (sharedRuntime) {
try {
sharedRuntime.streamRelease();
} catch {
/* ignore */
}
killProcessTree(sharedRuntime.proc);
sharedRuntime = null;
sharedConfigKey = null;
}
sharedInit = null;
}
function sessionErrorMessage(props: { error?: unknown }): string {
const err = props.error as { data?: { message?: string } } | undefined;
if (err && typeof err === 'object' && err.data && typeof err.data.message === 'string') {
return err.data.message;
}
return JSON.stringify(props.error) || 'OpenCode session error';
}
export class OpenCodeProvider implements AgentProvider {
readonly supportsNativeSlashCommands = false;
private readonly options: ProviderOptions;
private activeSessionId: string | undefined;
constructor(options: ProviderOptions = {}) {
this.options = options;
}
isSessionInvalid(err: unknown): boolean {
const msg = err instanceof Error ? err.message : String(err);
return STALE_SESSION_RE.test(msg);
}
query(input: QueryInput): AgentQuery {
if (input.continuation) {
this.activeSessionId = input.continuation;
} else {
this.activeSessionId = undefined;
}
const pending: string[] = [];
let waiting: (() => void) | null = null;
let ended = false;
let aborted = false;
const systemInstructions = input.systemContext?.instructions;
pending.push(wrapPromptWithContext(input.prompt, systemInstructions));
const kick = (): void => {
waiting?.();
};
const self = this;
const IDLE_TIMEOUT_MS = Number(process.env.OPENCODE_IDLE_TIMEOUT_MS) || 300_000;
async function* gen(): AsyncGenerator<ProviderEvent> {
let initYielded = false;
const rt = await ensureSharedRuntime(self.options);
const { client, stream } = rt;
while (!aborted) {
while (pending.length === 0 && !ended && !aborted) {
await new Promise<void>((resolve) => {
waiting = resolve;
});
waiting = null;
}
if (aborted) return;
if (pending.length === 0 && ended) return;
const text = pending.shift()!;
let sessionId = self.activeSessionId;
if (!sessionId) {
const created = await client.session.create();
if (created.error) {
throw new Error(`OpenCode: failed to create session: ${JSON.stringify(created.error)}`);
}
sessionId = created.data?.id;
if (!sessionId) throw new Error('OpenCode: failed to create session (no id)');
self.activeSessionId = sessionId;
}
if (!initYielded) {
yield { type: 'init', continuation: sessionId };
initYielded = true;
}
const promptRes = await client.session.promptAsync({
path: { id: sessionId },
body: { parts: [{ type: 'text', text }] },
});
if (promptRes.error) {
self.activeSessionId = undefined;
throw new Error(`OpenCode promptAsync: ${JSON.stringify(promptRes.error)}`);
}
const partTextByMessageId = new Map<string, string>();
const roleByMessageId = new Map<string, string>();
let lastEventAt = Date.now();
let eventTimedOut = false;
const timeoutCheck = setInterval(() => {
if (Date.now() - lastEventAt > IDLE_TIMEOUT_MS) {
log(`OpenCode event timeout (${IDLE_TIMEOUT_MS}ms) — clearing session ${sessionId}`);
eventTimedOut = true;
self.activeSessionId = undefined;
destroySharedRuntime();
kick();
}
}, 5000);
try {
turn: while (true) {
if (aborted) return;
if (eventTimedOut) {
throw new Error(`OpenCode event timeout (${IDLE_TIMEOUT_MS}ms)`);
}
const { value: ev, done } = await stream.next();
if (done) {
throw new Error('OpenCode SSE stream ended unexpectedly');
}
if (!ev?.type || ev.type === 'server.connected' || ev.type === 'server.heartbeat') continue;
lastEventAt = Date.now();
yield { type: 'activity' };
switch (ev.type) {
case 'message.updated': {
const info = ev.properties.info as { id?: string; role?: string } | undefined;
if (info?.id && info?.role) {
roleByMessageId.set(info.id, info.role);
}
break;
}
case 'message.part.updated': {
const part = ev.properties.part as { type?: string; messageID?: string; text?: string } | undefined;
if (part?.type === 'text' && part.messageID && part.text) {
partTextByMessageId.set(part.messageID, part.text);
}
break;
}
case 'permission.updated': {
const perm = ev.properties as { id?: string; sessionID?: string };
if (perm.sessionID === sessionId && perm.id) {
try {
await client.postSessionIdPermissionsPermissionId({
path: { id: sessionId, permissionID: perm.id },
body: { response: 'always' },
});
} catch (err) {
log(`Failed to auto-reply permission: ${err instanceof Error ? err.message : String(err)}`);
}
}
break;
}
case 'session.status': {
const props = ev.properties as {
sessionID?: string;
status?: { type?: string; attempt?: number; message?: string };
};
if (props.sessionID !== sessionId) break;
const st = props.status;
if (
st?.type === 'retry' &&
typeof st.attempt === 'number' &&
st.attempt >= SESSION_STATUS_RETRY_ERROR_AFTER &&
st.message
) {
self.activeSessionId = undefined;
throw new Error(`OpenCode retry limit (${st.attempt}): ${st.message}`);
}
break;
}
case 'session.error': {
const props = ev.properties as { sessionID?: string; error?: unknown };
if (props.sessionID === sessionId || props.sessionID === undefined) {
self.activeSessionId = undefined;
throw new Error(sessionErrorMessage(props));
}
break;
}
case 'session.idle': {
const sid = (ev.properties as { sessionID?: string }).sessionID;
if (sid === sessionId) {
break turn;
}
break;
}
default:
break;
}
}
} finally {
clearInterval(timeoutCheck);
}
let resultText = '';
for (const [msgId, role] of roleByMessageId) {
if (role === 'assistant') {
resultText = partTextByMessageId.get(msgId) ?? resultText;
}
}
yield { type: 'result', text: resultText || null };
}
}
return {
push: (message: string) => {
pending.push(wrapPromptWithContext(message, systemInstructions));
kick();
},
end: () => {
ended = true;
kick();
},
events: gen(),
abort: () => {
aborted = true;
this.activeSessionId = undefined;
kick();
destroySharedRuntime();
},
};
}
}
registerProvider('opencode', (opts) => new OpenCodeProvider(opts));
+16
View File
@@ -48,6 +48,22 @@ export function formatLocalTime(utcIso: string, timezone: string): string {
});
}
/**
* Compact sortable local stamp for log lines: "YYYY-MM-DD HH:mm" in `timezone`.
* (sv-SE is the one locale whose default rendering is this exact shape.)
*/
export function formatLocalStamp(date: Date, timezone: string): string {
return date.toLocaleString('sv-SE', {
timeZone: resolveTimezone(timezone),
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
}
function resolveContainerTimezone(): string {
const candidates = [process.env.TZ, Intl.DateTimeFormat().resolvedOptions().timeZone];
for (const tz of candidates) {
@@ -1,61 +0,0 @@
---
name: whatsapp-formatting
description: Format messages for WhatsApp, including mentions that render as real WhatsApp tags. Use when responding in a WhatsApp conversation (platform_id / chatJid ends with @s.whatsapp.net or @g.us).
---
# WhatsApp Message Formatting
WhatsApp uses its own lightweight markup and a phone-number-based mention syntax. The host's WhatsApp adapter (Baileys) handles markdown conversion automatically, but **mentions are only protocol-level mentions if you use the right syntax** — otherwise they render as plain text and don't notify the recipient.
## How to detect WhatsApp context
You're in a WhatsApp conversation when any of these are true:
- The chat JID / platform id ends with `@s.whatsapp.net` (1-on-1 DM)
- The chat JID / platform id ends with `@g.us` (group)
- Your inbound message metadata has `chatJid` matching the above
## Mentions — the important part
To tag a user so their name appears **bold and clickable** in WhatsApp and they get a push notification, write the `@` followed by their phone number digits (no `+`, no spaces, no display name):
```
@15551234567 can you confirm?
```
The adapter scans your outgoing text for `@<digits>` (515 digits, optional leading `+` is stripped) and tells WhatsApp to render them as real mention tags.
**The sender's phone JID is always in your inbound message metadata.** When a user writes to you, inbound `content.sender` looks like `15551234567@s.whatsapp.net`. The part before the `@` is exactly what you put after `@` when tagging them back.
### Wrong vs right
| You write | What recipients see |
|-----------|---------------------|
| `@Adam can you...` | Plain text `@Adam`. No tag, no notification. |
| `@15551234567 can you...` | Bold/blue **@Adam** (or whatever name they're saved as), notification fires. |
| `@+15551234567 ...` | Same as above — adapter strips the `+`. |
### Picking who to tag
- In a DM, there's no real need to tag the recipient (they already see every message), but tagging still works if you want emphasis.
- In a group, look at the `participants` / inbound `content.sender` to find the JID of the person you mean. Don't guess from display names — pushNames can collide and are not reliable.
- If you don't know the JID, just refer to the person by name in plain prose. Don't write `@<name>` — it won't tag and it will look like a tag that failed.
## Text styles
WhatsApp uses single-character delimiters, *not* doubled like standard Markdown.
| Style | Syntax | Renders as |
|-------|--------|------------|
| Bold | `*bold*` | **bold** |
| Italic | `_italic_` | *italic* |
| Strikethrough | `~strike~` | ~strike~ |
| Monospace | `` `code` `` | `code` |
| Block monospace | ```` ```block``` ```` | preformatted block |
The adapter converts standard Markdown (`**bold**`, `[link](url)`, `# heading`) to the WhatsApp-native form automatically, so you don't have to think about it — but be aware that single asterisks become italics, not bold.
## What not to do
- Don't write `<@U123>` (that's Slack), `<@!123>` (Discord), or any other channel's mention syntax.
- Don't paste a full JID like `@15551234567@s.whatsapp.net` in the text — only the digits before the JID's `@` go after your `@`.
- Don't try to tag display names. WhatsApp has no display-name-based mention API.
@@ -1,19 +0,0 @@
## WhatsApp mentions — always use phone digits
When you are replying in a WhatsApp conversation (the inbound message's `chatJid` ends with `@s.whatsapp.net` for a DM or `@g.us` for a group), and you want to tag a person so their name appears **bold and clickable** with a push notification, write `@` followed by their phone-number digits — never the display name.
**The sender's phone JID is in your inbound message metadata** at `content.sender` (e.g. `15551234567@s.whatsapp.net`). The part before the `@` is exactly what you put after `@` when tagging them.
| You write | What recipients see |
|-----------|---------------------|
| `@Adam, can you...` | Plain text. No tag, no notification. |
| `@15551234567, can you...` | Bold/blue **@Adam** (whatever name they're saved as), notification fires. |
| `@+15551234567 ...` | Same as above — the adapter strips the `+` automatically. |
The host adapter scans your outbound text for `@<515 digits>` (with optional leading `+`) and tells WhatsApp to render those as real mention tags. If the digits aren't in the text, the tag doesn't render — no exceptions.
### In groups
Tag the person you're addressing using their JID from inbound metadata (look at the most recent message from them). Don't guess — pushNames collide and aren't reliable.
If you don't know someone's JID, refer to them by name in plain prose. Do not write `@<displayname>` hoping it works.
-504
View File
@@ -1,504 +0,0 @@
;;; nanoclaw.el --- Emacs interface for NanoClaw AI assistant -*- lexical-binding: t -*-
;; Author: NanoClaw
;; Version: 0.1.0
;; Package-Requires: ((emacs "27.1"))
;; Keywords: ai, assistant, chat
;;
;; Vanilla Emacs (init.el):
;; (load-file "~/src/nanoclaw/emacs/nanoclaw.el")
;; (global-set-key (kbd "C-c n c") #'nanoclaw-chat)
;; (global-set-key (kbd "C-c n o") #'nanoclaw-org-send)
;;
;; Spacemacs (~/.spacemacs, in dotspacemacs/user-config):
;; (load-file "~/src/nanoclaw/emacs/nanoclaw.el")
;; (spacemacs/set-leader-keys "aNc" #'nanoclaw-chat)
;; (spacemacs/set-leader-keys "aNo" #'nanoclaw-org-send)
;;
;; Doom Emacs (config.el):
;; (load (expand-file-name "~/src/nanoclaw/emacs/nanoclaw.el"))
;; (map! :leader
;; :prefix ("N" . "NanoClaw")
;; :desc "Chat buffer" "c" #'nanoclaw-chat
;; :desc "Send org" "o" #'nanoclaw-org-send)
;; ;; Evil users: teach evil about the C-c C-c send binding
;; (after! evil
;; (evil-define-key '(normal insert) nanoclaw-chat-mode-map
;; (kbd "C-c C-c") #'nanoclaw-chat-send))
;;; Code:
(require 'cl-lib)
(require 'url)
(require 'json)
(require 'org)
;; ---------------------------------------------------------------------------
;; Customization
(defgroup nanoclaw nil
"NanoClaw AI assistant interface."
:group 'tools
:prefix "nanoclaw-")
(defcustom nanoclaw-host "localhost"
"Hostname where NanoClaw is running."
:type 'string
:group 'nanoclaw)
(defcustom nanoclaw-port 8766
"Port for the NanoClaw Emacs channel HTTP server."
:type 'integer
:group 'nanoclaw)
(defcustom nanoclaw-auth-token nil
"Bearer token for NanoClaw authentication (matches EMACS_AUTH_TOKEN in .env).
Leave nil if EMACS_AUTH_TOKEN is not set."
:type '(choice (const nil) string)
:group 'nanoclaw)
(defcustom nanoclaw-poll-interval 1.5
"Seconds between response polls when waiting for a reply."
:type 'number
:group 'nanoclaw)
(defcustom nanoclaw-agent-name "Andy"
"Display name for the NanoClaw agent (matches ASSISTANT_NAME in .env)."
:type 'string
:group 'nanoclaw)
(defcustom nanoclaw-convert-to-org t
"When non-nil, convert agent responses to org-mode format.
Uses pandoc when available; falls back to regex substitutions."
:type 'boolean
:group 'nanoclaw)
(defcustom nanoclaw-timestamp-format "%H:%M"
"Format string for timestamps shown next to agent replies in the chat buffer.
Passed to `format-time-string'. Set to nil to suppress timestamps."
:type '(choice (const nil) string)
:group 'nanoclaw)
;; ---------------------------------------------------------------------------
;; Formatting helpers
(defun nanoclaw--to-org (text)
"Convert TEXT (markdown or plain) to org-mode markup.
Tries pandoc -f gfm -t org when available; falls back to regex."
(if (not nanoclaw-convert-to-org)
text
(if (executable-find "pandoc")
(with-temp-buffer
(insert text)
(let* ((coding-system-for-read 'utf-8)
(coding-system-for-write 'utf-8)
(exit (call-process-region
(point-min) (point-max)
"pandoc" t t nil "-f" "gfm" "-t" "org" "--wrap=none")))
(if (zerop exit)
(string-trim (buffer-string))
text)))
(nanoclaw--md-to-org-regex text))))
;; NOTE: This function expects standard markdown as input (e.g. **bold**, *italic*).
;; Agents responding on this channel must output markdown, not org-mode syntax.
;; If the agent outputs org-mode directly, markers like *bold* will be incorrectly
;; re-converted to /bold/ by the italic rule.
(defun nanoclaw--md-to-org-regex (text)
"Lightweight markdown → org conversion using regexp substitutions."
(let ((s text))
;; Fenced code blocks ```lang\n…\n``` → #+begin_src lang\n…\n#+end_src
;; (must run before inline-code to avoid mangling backticks)
(setq s (replace-regexp-in-string
"```\\([a-zA-Z0-9_-]*\\)\n\\(\\(?:.\\|\n\\)*?\\)```"
(lambda (m)
(let ((lang (match-string 1 m))
(body (match-string 2 m)))
(concat "#+begin_src " (if (string-empty-p lang) "text" lang)
"\n" body "#+end_src")))
s t))
;; Bold **text** → *text*, italic *text* → /text/
;; Two-pass to prevent the italic regex from re-matching the bold result:
;; 1. Mark bold spans with a placeholder (control char \x01)
(setq s (replace-regexp-in-string "\\*\\*\\(.+?\\)\\*\\*" "\x01\\1\x01" s))
;; 2. Convert remaining single-star spans to italic
(setq s (replace-regexp-in-string "\\*\\(.+?\\)\\*" "/\\1/" s))
;; 3. Resolve bold placeholders to org bold markers
(setq s (replace-regexp-in-string "\x01\\(.+?\\)\x01" "*\\1*" s))
;; Strikethrough ~~text~~ → +text+
(setq s (replace-regexp-in-string "~~\\(.+?\\)~~" "+\\1+" s))
;; Underline __text__ → _text_
(setq s (replace-regexp-in-string "__\\(.+?\\)__" "_\\1_" s))
;; Inline code `code` → ~code~
(setq s (replace-regexp-in-string "`\\([^`]+\\)`" "~\\1~" s))
;; ATX headings ## … → ** …
(setq s (replace-regexp-in-string
"^\\(#+\\) "
(lambda (m) (concat (make-string (length (match-string 1 m)) ?*) " "))
s))
;; Links [text](url) → [[url][text]]
(setq s (replace-regexp-in-string
"\\[\\([^]]+\\)\\](\\([^)]+\\))" "[[\\2][\\1]]" s))
s))
(defun nanoclaw--format-timestamp ()
"Return a formatted timestamp string, or nil if disabled."
(when nanoclaw-timestamp-format
(format-time-string nanoclaw-timestamp-format)))
;; ---------------------------------------------------------------------------
;; Internal state
(defvar nanoclaw--poll-timer nil
"Timer used to poll for responses in the chat buffer.")
(defvar nanoclaw--last-timestamp 0
"Epoch ms of the most recently received message.")
(defvar nanoclaw--pending nil
"Non-nil while waiting for a response.")
(defvar-local nanoclaw--thinking-dot-count 0
"Dot cycle counter for the animated thinking indicator.")
(defvar-local nanoclaw--input-beg nil
"Marker for the start of the current user input area.")
;; ---------------------------------------------------------------------------
;; HTTP helpers
(defun nanoclaw--url (path)
"Return the full URL for PATH on the NanoClaw server."
(format "http://%s:%d%s" nanoclaw-host nanoclaw-port path))
(defun nanoclaw--headers ()
"Return alist of HTTP headers for NanoClaw requests."
(let ((hdrs '(("Content-Type" . "application/json"))))
(when nanoclaw-auth-token
(push (cons "Authorization" (concat "Bearer " nanoclaw-auth-token)) hdrs))
hdrs))
(defun nanoclaw--post (text callback)
"POST TEXT to NanoClaw and call CALLBACK with the response alist."
(let* ((url-request-method "POST")
(url-request-extra-headers (nanoclaw--headers))
(url-request-data (encode-coding-string
(json-encode `((text . ,text)))
'utf-8)))
(url-retrieve
(nanoclaw--url "/api/message")
(lambda (status)
(if (plist-get status :error)
(message "NanoClaw: POST error %s" (plist-get status :error))
(goto-char (point-min))
(re-search-forward "\n\n" nil t)
(let ((data (ignore-errors (json-read))))
(funcall callback data))))
nil t t)))
(defun nanoclaw--poll (since callback)
"GET messages newer than SINCE (epoch ms) and call CALLBACK with the list."
(let* ((url-request-method "GET")
(url-request-extra-headers (nanoclaw--headers)))
(url-retrieve
(nanoclaw--url (format "/api/messages?since=%d" since))
(lambda (status)
(unless (plist-get status :error)
(goto-char (point-min))
(re-search-forward "\n\n" nil t)
(let* ((raw (buffer-substring-no-properties (point) (point-max)))
(body (decode-coding-string raw 'utf-8))
(data (ignore-errors (json-read-from-string body)))
(msgs (cdr (assq 'messages data))))
(when msgs (funcall callback (append msgs nil))))))
nil t t)))
;; ---------------------------------------------------------------------------
;; Chat buffer
(defvar nanoclaw-chat-mode-map
(let ((map (make-sparse-keymap)))
(define-key map (kbd "RET") #'newline)
(define-key map (kbd "<return>") #'newline)
(define-key map (kbd "C-c C-c") #'nanoclaw-chat-send)
map)
"Keymap for `nanoclaw-chat-mode'.")
(define-derived-mode nanoclaw-chat-mode org-mode "NanoClaw"
"Major mode for the NanoClaw chat buffer.
Derives from org-mode so that org markup (headings, bold, code blocks,
etc.) is fontified automatically. RET and <return> insert plain newlines
for multi-line input; send with C-c C-c."
(setq-local word-wrap t)
(visual-line-mode 1)
;; Disable org features that conflict with a linear chat buffer
(setq-local org-return-follows-link nil)
(setq-local org-cycle-emulate-tab nil)
;; Ensure send binding beats org-mode's C-c C-c via the buffer-local map
(local-set-key (kbd "C-c C-c") #'nanoclaw-chat-send))
(defun nanoclaw--advance-input-beg ()
"Move `nanoclaw--input-beg' to point-max in the chat buffer."
(with-current-buffer (nanoclaw--chat-buffer)
(when nanoclaw--input-beg (set-marker nanoclaw--input-beg nil))
(setq nanoclaw--input-beg (copy-marker (point-max)))))
(defun nanoclaw--chat-buffer ()
"Return the NanoClaw chat buffer, creating it if necessary."
(or (get-buffer "*NanoClaw*")
(with-current-buffer (get-buffer-create "*NanoClaw*")
(nanoclaw-chat-mode)
(set-buffer-file-coding-system 'utf-8)
(add-hook 'kill-buffer-hook #'nanoclaw--stop-poll nil t)
(nanoclaw--insert-header)
(setq nanoclaw--input-beg (copy-marker (point-max)))
(current-buffer))))
(defun nanoclaw--insert-header ()
"Insert the welcome header into the chat buffer."
(let ((inhibit-read-only t))
(insert (propertize
(format "── NanoClaw (%s) ──────────────────────────────\n\n"
nanoclaw-agent-name)
'face 'font-lock-comment-face))))
(defun nanoclaw--chat-insert (speaker text)
"Append SPEAKER: TEXT to the chat buffer."
(with-current-buffer (nanoclaw--chat-buffer)
(let* ((inhibit-read-only t)
(is-agent (not (string= speaker "You")))
(display-text (if is-agent (nanoclaw--to-org text) text))
(ts (nanoclaw--format-timestamp))
(label (if ts (format "%s [%s]" speaker ts) speaker))
(face (if is-agent 'font-lock-string-face 'font-lock-keyword-face)))
(goto-char (point-max))
(insert (propertize (concat label ": ") 'face face))
(insert display-text "\n\n")
(goto-char (point-max))
(when is-agent
(nanoclaw--advance-input-beg)))))
;;;###autoload
(defun nanoclaw-chat ()
"Open the NanoClaw chat buffer."
(interactive)
(pop-to-buffer (nanoclaw--chat-buffer))
(goto-char (point-max)))
(defun nanoclaw-chat-send ()
"Send the accumulated input area as a message to NanoClaw.
Use C-c C-c to send; RET inserts a plain newline for multi-line messages."
(interactive)
(when nanoclaw--pending
(message "NanoClaw: waiting for previous response...")
(cl-return-from nanoclaw-chat-send))
(let* ((beg (if (and nanoclaw--input-beg (marker-buffer nanoclaw--input-beg))
(marker-position nanoclaw--input-beg)
(line-beginning-position)))
(text (string-trim (buffer-substring-no-properties beg (point-max)))))
(when (string-empty-p text)
(user-error "Nothing to send"))
(let ((inhibit-read-only t))
(delete-region beg (point-max)))
(nanoclaw--chat-insert "You" text)
(nanoclaw--advance-input-beg)
(setq nanoclaw--pending t)
(nanoclaw--post text
(lambda (data)
(when data
(setq nanoclaw--last-timestamp
(or (cdr (assq 'timestamp data))
nanoclaw--last-timestamp))
(nanoclaw--start-thinking)
(nanoclaw--start-poll))))))
(defun nanoclaw--start-poll ()
"Start polling for new messages."
(nanoclaw--stop-poll)
(setq nanoclaw--poll-timer
(run-with-timer nanoclaw-poll-interval nanoclaw-poll-interval
#'nanoclaw--poll-tick)))
(defun nanoclaw--stop-poll ()
"Stop the polling timer."
(when nanoclaw--poll-timer
(cancel-timer nanoclaw--poll-timer)
(setq nanoclaw--poll-timer nil)))
(defun nanoclaw--start-thinking ()
"Insert an animated thinking indicator at the end of the chat buffer."
(with-current-buffer (nanoclaw--chat-buffer)
(let ((inhibit-read-only t))
(goto-char (point-max))
(setq nanoclaw--thinking-dot-count 1)
(insert (propertize (format "%s: .\n\n" nanoclaw-agent-name)
'nanoclaw-thinking t
'face 'font-lock-string-face)))))
(defun nanoclaw--tick-thinking ()
"Advance the dot animation in the thinking indicator."
(let ((buf (get-buffer "*NanoClaw*")))
(when buf
(with-current-buffer buf
(when nanoclaw--pending
(let* ((inhibit-read-only t)
(pos (text-property-any (point-min) (point-max)
'nanoclaw-thinking t)))
(when pos
(let* ((end (or (next-single-property-change
pos 'nanoclaw-thinking) (point-max)))
(n (1+ (mod nanoclaw--thinking-dot-count 3))))
(setq nanoclaw--thinking-dot-count n)
(delete-region pos end)
(save-excursion
(goto-char pos)
(insert (propertize
(format "%s: %s\n\n" nanoclaw-agent-name
(make-string n ?.))
'nanoclaw-thinking t
'face 'font-lock-string-face)))))))))))
(defun nanoclaw--clear-thinking ()
"Remove the thinking indicator from the chat buffer."
(let ((buf (get-buffer "*NanoClaw*")))
(when buf
(with-current-buffer buf
(let* ((inhibit-read-only t)
(pos (text-property-any (point-min) (point-max)
'nanoclaw-thinking t)))
(when pos
(delete-region pos (or (next-single-property-change
pos 'nanoclaw-thinking) (point-max)))))))))
(defun nanoclaw--poll-tick ()
"Poll for new messages and insert them into the chat buffer."
(nanoclaw--tick-thinking)
(nanoclaw--poll
nanoclaw--last-timestamp
(lambda (msgs)
(dolist (msg msgs)
(let ((text (cdr (assq 'text msg)))
(ts (cdr (assq 'timestamp msg))))
(when (and text (> ts nanoclaw--last-timestamp))
(setq nanoclaw--last-timestamp ts)
(nanoclaw--clear-thinking)
(nanoclaw--chat-insert nanoclaw-agent-name text))))
(when msgs
(setq nanoclaw--pending nil)
(nanoclaw--stop-poll)))))
;; ---------------------------------------------------------------------------
;; Org integration
;;;###autoload
(defun nanoclaw-org-send ()
"Send the current org subtree to NanoClaw and insert the response as a child.
If a region is active, send the region text instead."
(interactive)
(unless (derived-mode-p 'org-mode)
(user-error "Not in an org-mode buffer"))
(let ((text (if (use-region-p)
(buffer-substring-no-properties (region-beginning) (region-end))
(nanoclaw--org-subtree-text))))
(when (string-empty-p (string-trim text))
(user-error "Nothing to send"))
(message "NanoClaw: sending to %s..." nanoclaw-agent-name)
(let ((marker (point-marker))
(buf (current-buffer)))
(nanoclaw--post
text
(lambda (data)
(let* ((ts (or (cdr (assq 'timestamp data)) (nanoclaw--now-ms)))
(level (with-current-buffer buf
(save-excursion (goto-char marker) (org-outline-level))))
(ph (with-current-buffer buf
(save-excursion
(goto-char marker)
(nanoclaw--org-insert-placeholder level)))))
(nanoclaw--poll-until-response
ts
(lambda (response)
(with-current-buffer buf
(save-excursion
(when (marker-buffer ph)
(let* ((inhibit-read-only t)
(beg (marker-position ph))
(end (save-excursion
(goto-char (1+ beg))
(org-next-visible-heading 1)
(point))))
(delete-region beg end))
(set-marker ph nil))
(goto-char marker)
(nanoclaw--org-insert-response response))))
(lambda ()
(message "NanoClaw: timed out waiting for response")
(when (marker-buffer ph)
(with-current-buffer (marker-buffer ph)
(let* ((inhibit-read-only t)
(beg (marker-position ph))
(end (save-excursion
(goto-char (1+ beg))
(org-next-visible-heading 1)
(point))))
(delete-region beg end))
(set-marker ph nil)))))))))))
(defun nanoclaw--org-insert-placeholder (level)
"Insert a processing child heading at LEVEL+1 and return a marker at its start."
(org-back-to-heading t)
(org-end-of-subtree t t)
(let ((beg (point)))
(insert "\n" (make-string (1+ level) ?*) " "
nanoclaw-agent-name " [processing...]\n\n")
(copy-marker beg)))
(defun nanoclaw--org-subtree-text ()
"Return the text of the org subtree at point (heading + body)."
(org-with-wide-buffer
(org-back-to-heading t)
(let ((start (point))
(end (progn (org-end-of-subtree t t) (point))))
(buffer-substring-no-properties start end))))
(defun nanoclaw--org-insert-response (text)
"Insert TEXT as a child org heading under the current subtree."
(org-back-to-heading t)
(let* ((level (org-outline-level))
(child-stars (make-string (1+ level) ?*))
(timestamp (format-time-string "[%Y-%m-%d %a %H:%M]"))
(body (nanoclaw--to-org text)))
(org-end-of-subtree t t)
(insert "\n" child-stars " " nanoclaw-agent-name " " timestamp "\n"
body "\n")))
(defun nanoclaw--now-ms ()
"Return current time as milliseconds since epoch."
(let ((time (current-time)))
(+ (* (+ (* (car time) 65536) (cadr time)) 1000)
(/ (caddr time) 1000))))
(defun nanoclaw--poll-until-response (since callback timeout-fn &optional attempts)
"Poll until a message newer than SINCE arrives, then call CALLBACK.
Calls TIMEOUT-FN after 60 attempts (~90s)."
(let ((n (or attempts 0)))
(if (>= n 60)
(funcall timeout-fn)
(nanoclaw--poll
since
(lambda (msgs)
(let ((fresh (seq-filter (lambda (m) (> (cdr (assq 'timestamp m)) since))
msgs)))
(if fresh
(let ((text (mapconcat (lambda (m) (cdr (assq 'text m)))
fresh "\n")))
(funcall callback text))
(run-with-timer nanoclaw-poll-interval nil
#'nanoclaw--poll-until-response
since callback timeout-fn (1+ n)))))))))
;; ---------------------------------------------------------------------------
(provide 'nanoclaw)
;;; nanoclaw.el ends here
+1 -19
View File
@@ -24,31 +24,13 @@
"test:watch": "vitest"
},
"dependencies": {
"@beeper/chat-adapter-matrix": "^0.2.0",
"@bitbasti/chat-adapter-webex": "^0.1.0",
"@chat-adapter/discord": "4.29.0",
"@chat-adapter/gchat": "4.29.0",
"@chat-adapter/github": "4.29.0",
"@chat-adapter/linear": "4.29.0",
"@chat-adapter/slack": "4.29.0",
"@chat-adapter/state-memory": "4.29.0",
"@chat-adapter/teams": "4.29.0",
"@chat-adapter/telegram": "4.29.0",
"@chat-adapter/whatsapp": "4.29.0",
"@clack/core": "^1.2.0",
"@clack/prompts": "^1.2.0",
"@onecli-sh/sdk": "^0.3.1",
"@resend/chat-sdk-adapter": "^0.1.1",
"@types/qrcode": "^1.5.6",
"@whiskeysockets/baileys": "7.0.0-rc.9",
"better-sqlite3": "11.10.0",
"chat": "4.29.0",
"chat-adapter-imessage": "^0.1.1",
"cron-parser": "5.5.0",
"kleur": "^4.1.5",
"pino": "^9.6.0",
"qrcode": "^1.5.4",
"wechat-ilink-client": "^0.1.0"
"kleur": "^4.1.5"
},
"devDependencies": {
"@eslint/js": "^9.35.0",
+4 -3916
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* Initialize the scratch CLI agent used during `/setup`.
* Initialize the scratch CLI agent used during `/new-setup`.
*
* Creates the synthetic `cli:local` user, grants owner role if no owner
* exists yet, builds an agent group with a minimal CLAUDE.md, and wires it
+5 -13
View File
@@ -1,11 +1,10 @@
#!/usr/bin/env bash
#
# Install the Slack adapter, persist SLACK_BOT_TOKEN plus the mode-specific
# secret (SLACK_APP_TOKEN for Socket Mode, SLACK_SIGNING_SECRET for webhook) to
# Install the Slack adapter, persist SLACK_BOT_TOKEN + SLACK_SIGNING_SECRET to
# .env + data/env/env, and restart the service. Non-interactive — the
# operator-facing app creation walkthrough + credential paste live in
# setup/channels/slack.ts. Credentials come in via env vars:
# SLACK_BOT_TOKEN, and SLACK_APP_TOKEN and/or SLACK_SIGNING_SECRET.
# SLACK_BOT_TOKEN, SLACK_SIGNING_SECRET.
#
# Emits exactly one status block on stdout (ADD_SLACK) at the end. All chatty
# progress messages go to stderr so setup:auto's raw-log capture sees the full
@@ -42,10 +41,8 @@ if [ -z "${SLACK_BOT_TOKEN:-}" ]; then
emit_status failed "SLACK_BOT_TOKEN env var not set"
exit 1
fi
# Socket Mode authenticates with SLACK_APP_TOKEN; webhook mode with
# SLACK_SIGNING_SECRET. Require at least one.
if [ -z "${SLACK_APP_TOKEN:-}" ] && [ -z "${SLACK_SIGNING_SECRET:-}" ]; then
emit_status failed "Set SLACK_APP_TOKEN (Socket Mode) or SLACK_SIGNING_SECRET (webhook)"
if [ -z "${SLACK_SIGNING_SECRET:-}" ]; then
emit_status failed "SLACK_SIGNING_SECRET env var not set"
exit 1
fi
@@ -101,12 +98,7 @@ upsert_env() {
fi
}
upsert_env SLACK_BOT_TOKEN "$SLACK_BOT_TOKEN"
if [ -n "${SLACK_APP_TOKEN:-}" ]; then
upsert_env SLACK_APP_TOKEN "$SLACK_APP_TOKEN"
fi
if [ -n "${SLACK_SIGNING_SECRET:-}" ]; then
upsert_env SLACK_SIGNING_SECRET "$SLACK_SIGNING_SECRET"
fi
upsert_env SLACK_SIGNING_SECRET "$SLACK_SIGNING_SECRET"
# Container reads from data/env/env (the host mounts it).
mkdir -p data/env
+1 -1
View File
@@ -16,7 +16,7 @@ PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
# Keep in sync with .claude/skills/add-whatsapp/SKILL.md.
BAILEYS_VERSION="@whiskeysockets/baileys@7.0.0-rc.9"
BAILEYS_VERSION="@whiskeysockets/baileys@6.17.16"
QRCODE_VERSION="qrcode@1.5.4"
QRCODE_TYPES_VERSION="@types/qrcode@1.5.6"
PINO_VERSION="pino@9.6.0"
+2 -2
View File
@@ -7,7 +7,7 @@
* already exists unless --force is passed.
*
* The actual user-facing prompt (subscription vs API key, paste the token)
* stays in the /setup SKILL.md. This step is just the machine side:
* stays in the /new-setup SKILL.md. This step is just the machine side:
* it calls `onecli secrets list` / `onecli secrets create` and emits a
* structured status block. The token value is never logged.
*/
@@ -124,7 +124,7 @@ export async function run(args: string[]): Promise<void> {
emitStatus('AUTH', {
STATUS: 'failed',
ERROR: 'onecli_list_failed',
HINT: 'Is OneCLI running? Run `/setup` from the onecli step.',
HINT: 'Is OneCLI running? Run `/new-setup` from the onecli step.',
LOG: 'logs/setup.log',
});
process.exit(1);
+48 -116
View File
@@ -4,23 +4,21 @@
* `runSlackChannel(displayName)` walks the operator from a bare Slack
* workspace through a running bot, then stops before wiring an agent:
*
* 1. Ask the delivery mode: Socket Mode (outbound WebSocket, no public
* URL) or a public webhook
* 2. Walk through creating a Slack app (api.slack.com/apps) scopes,
* events, and the mode-specific credential (app-level token for
* Socket Mode, signing secret for webhook)
* 3. Paste the bot token + that credential (clack password prompts)
* 4. Validate via auth.test resolves workspace + bot identity
* 5. Install the adapter (setup/add-slack.sh, non-interactive)
* 6. Print the post-install checklist (Socket Mode: just DM the bot;
* webhook: set the public Request URL in Event Subscriptions), then
* `/manage-channels` to wire an agent.
* 1. Walk through creating a Slack app (api.slack.com/apps) scopes,
* event subscriptions, and signing secret
* 2. Paste the bot token + signing secret (clack password prompts)
* 3. Validate via auth.test resolves workspace + bot identity
* 4. Install the adapter (setup/add-slack.sh, non-interactive)
* 5. Print the post-install checklist: set the public webhook URL in
* Slack's Event Subscriptions, DM the bot to bootstrap the channel,
* then `/manage-channels` to wire an agent.
*
* Why no welcome DM here: opening an unsolicited DM would need `im:write`
* scope we don't force the SKILL.md to require and in webhook mode inbound
* events don't flow until the public Event Subscriptions URL is configured.
* Shipping an honest "here's what's left" note is better than a welcome DM
* the user won't receive until they finish wiring Slack up.
* Why no welcome DM here: unlike Discord/Telegram (gateway / long-poll),
* Slack needs a public Event Subscriptions URL for inbound events, and
* opening an unsolicited DM would need `im:write` scope we don't force
* the SKILL.md to require. Shipping a honest "here's what's left" note
* is better than a welcome DM the user won't receive until they
* configure the webhook anyway.
*
* All output obeys the three-level contract. See docs/setup-flow.md.
*/
@@ -28,7 +26,6 @@ import * as p from '@clack/prompts';
import k from 'kleur';
import * as setupLog from '../logs.js';
import { brightSelect } from '../lib/bright-select.js';
import { confirmThenOpen } from '../lib/browser.js';
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
import { wrapForGutter } from '../lib/theme.js';
@@ -43,28 +40,16 @@ interface WorkspaceInfo {
botUserId: string;
}
// Socket Mode (SLACK_APP_TOKEN, xapp-…) needs no public URL; webhook mode
// (SLACK_SIGNING_SECRET) needs a public Request URL. The adapter picks the mode
// purely from SLACK_APP_TOKEN's presence — this choice just decides which
// credential to collect and which post-install guidance to show.
type SlackMode = 'socket' | 'webhook';
// displayName is reserved for when we start wiring the first agent here.
// Kept to match the `run<X>Channel(displayName)` signature every other
// channel driver uses, so auto.ts can dispatch without a branch.
export async function runSlackChannel(_displayName: string): Promise<void> {
const mode = await askSlackMode();
await walkThroughAppCreation(mode);
await walkThroughAppCreation();
const token = await collectBotToken();
const appToken = mode === 'socket' ? await collectAppToken() : undefined;
const signingSecret = mode === 'webhook' ? await collectSigningSecret() : undefined;
const signingSecret = await collectSigningSecret();
const info = await validateSlackToken(token);
const env: Record<string, string> = { SLACK_BOT_TOKEN: token };
if (appToken) env.SLACK_APP_TOKEN = appToken;
if (signingSecret) env.SLACK_SIGNING_SECRET = signingSecret;
const install = await runQuietChild(
'slack-install',
'bash',
@@ -74,9 +59,11 @@ export async function runSlackChannel(_displayName: string): Promise<void> {
done: 'Slack adapter installed.',
},
{
env,
env: {
SLACK_BOT_TOKEN: token,
SLACK_SIGNING_SECRET: signingSecret,
},
extraFields: {
MODE: mode,
BOT_NAME: info.botName,
TEAM_NAME: info.teamName,
TEAM_ID: info.teamId,
@@ -84,52 +71,21 @@ export async function runSlackChannel(_displayName: string): Promise<void> {
},
);
if (!install.ok) {
await fail('slack-install', "Couldn't connect Slack.", 'See logs/setup-steps/ for details, then retry setup.');
await fail(
'slack-install',
"Couldn't connect Slack.",
'See logs/setup-steps/ for details, then retry setup.',
);
}
showPostInstallChecklist(info, mode);
showPostInstallChecklist(info);
}
async function askSlackMode(): Promise<SlackMode> {
const choice = ensureAnswer(
await brightSelect<SlackMode>({
message: 'How should Slack deliver events to NanoClaw?',
initialValue: 'socket',
options: [
{
value: 'socket',
label: 'Socket Mode',
hint: 'no public URL — recommended for local or behind NAT',
},
{
value: 'webhook',
label: 'Public webhook',
hint: 'needs a public HTTPS Request URL',
},
],
}),
);
setupLog.userInput('slack_mode', String(choice));
return choice;
}
async function walkThroughAppCreation(mode: SlackMode): Promise<void> {
const credSteps =
mode === 'socket'
? [
' 4. Basic Information → App-Level Tokens → "Generate Token and',
' Scopes" → add the connections:write scope → copy it (xapp-…)',
' 5. Socket Mode → toggle "Enable Socket Mode" on',
' 6. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)',
]
: [
' 4. Basic Information → copy the "Signing Secret"',
' 5. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)',
];
async function walkThroughAppCreation(): Promise<void> {
p.note(
[
"You'll create a Slack app that the assistant talks through.",
'Free and stays inside the workspaces you pick.',
"Free and stays inside the workspaces you pick.",
'',
' 1. Create a new app "From scratch", name it, pick a workspace',
' 2. OAuth & Permissions → add Bot Token Scopes:',
@@ -137,7 +93,8 @@ async function walkThroughAppCreation(mode: SlackMode): Promise<void> {
' channels:read, groups:read, users:read, reactions:write',
' 3. App Home → enable "Messages Tab" and "Allow users to send',
' slash commands and messages from the messages tab"',
...credSteps,
' 4. Basic Information → copy the "Signing Secret"',
' 5. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)',
'',
k.dim(SLACK_APPS_URL),
].join('\n'),
@@ -147,7 +104,7 @@ async function walkThroughAppCreation(mode: SlackMode): Promise<void> {
ensureAnswer(
await p.confirm({
message: mode === 'socket' ? 'Got your bot token and app-level token?' : 'Got your bot token and signing secret?',
message: 'Got your bot token and signing secret?',
initialValue: true,
}),
);
@@ -167,7 +124,10 @@ async function collectBotToken(): Promise<string> {
}),
);
const token = (answer as string).trim();
setupLog.userInput('slack_bot_token', `${token.slice(0, 10)}${token.slice(-4)}`);
setupLog.userInput(
'slack_bot_token',
`${token.slice(0, 10)}${token.slice(-4)}`,
);
return token;
}
@@ -188,26 +148,11 @@ async function collectSigningSecret(): Promise<string> {
}),
);
const secret = (answer as string).trim();
setupLog.userInput('slack_signing_secret', `${secret.slice(0, 4)}${secret.slice(-4)}`);
return secret;
}
async function collectAppToken(): Promise<string> {
const answer = ensureAnswer(
await p.password({
message: 'Paste your Slack app-level token (Socket Mode)',
validate: (v) => {
const t = (v ?? '').trim();
if (!t) return 'App-level token is required for Socket Mode';
if (!t.startsWith('xapp-')) return 'App-level tokens start with xapp-';
if (t.length < 24) return "That's shorter than a real Slack app-level token";
return undefined;
},
}),
setupLog.userInput(
'slack_signing_secret',
`${secret.slice(0, 4)}${secret.slice(-4)}`,
);
const token = (answer as string).trim();
setupLog.userInput('slack_app_token', `${token.slice(0, 10)}${token.slice(-4)}`);
return token;
return secret;
}
async function validateSlackToken(token: string): Promise<WorkspaceInfo> {
@@ -232,7 +177,9 @@ async function validateSlackToken(token: string): Promise<WorkspaceInfo> {
};
const elapsedS = Math.round((Date.now() - start) / 1000);
if (data.ok && data.team && data.user) {
s.stop(`Connected to ${data.team} as @${data.user}. ${k.dim(`(${elapsedS}s)`)}`);
s.stop(
`Connected to ${data.team} as @${data.user}. ${k.dim(`(${elapsedS}s)`)}`,
);
const info: WorkspaceInfo = {
teamName: data.team,
teamId: data.team_id ?? '',
@@ -266,30 +213,15 @@ async function validateSlackToken(token: string): Promise<WorkspaceInfo> {
setupLog.step('slack-validate', 'failed', Date.now() - start, {
ERROR: message,
});
await fail('slack-validate', "Couldn't reach Slack.", 'Check your internet connection and retry setup.');
await fail(
'slack-validate',
"Couldn't reach Slack.",
'Check your internet connection and retry setup.',
);
}
}
function showPostInstallChecklist(info: WorkspaceInfo, mode: SlackMode): void {
if (mode === 'socket') {
p.note(
wrapForGutter(
[
`The Slack adapter is installed in Socket Mode and your creds are saved. No public URL needed — ${info.teamName} reaches NanoClaw over an outbound WebSocket.`,
'',
` 1. DM @${info.botName} from Slack once — that bootstraps the`,
' messaging group. Then run `/manage-channels` in `claude` to',
' wire an agent to it.',
'',
' Note: keep the NanoClaw host running to hold the socket open —',
' Slack does not retry delivery while it is down.',
].join('\n'),
6,
),
'Finish setting up Slack',
);
return;
}
function showPostInstallChecklist(info: WorkspaceInfo): void {
p.note(
wrapForGutter(
[
+2 -2
View File
@@ -1,8 +1,8 @@
/**
* Step: cli-agent Create the scratch CLI agent for `/setup`.
* Step: cli-agent Create the scratch CLI agent for `/new-setup`.
*
* Thin wrapper around `scripts/init-cli-agent.ts`. Emits a status block so
* /setup SKILL.md can parse the result without having to read the
* /new-setup SKILL.md can parse the result without having to read the
* script's plain stdout.
*
* Args:
-229
View File
@@ -1,229 +0,0 @@
/**
* Step: groups Fetch group metadata from messaging platforms, write to DB.
* WhatsApp requires an upfront sync (Baileys groupFetchAllParticipating).
* Other channels discover group names at runtime this step auto-skips for them.
* Replaces 05-sync-groups.sh + 05b-list-groups.sh
*/
import { execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import Database from 'better-sqlite3';
import { STORE_DIR } from '../src/config.js';
import { log } from '../src/log.js';
import { emitStatus } from './status.js';
function parseArgs(args: string[]): { list: boolean; limit: number } {
let list = false;
let limit = 30;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--list') list = true;
if (args[i] === '--limit' && args[i + 1]) {
limit = parseInt(args[i + 1], 10);
i++;
}
}
return { list, limit };
}
export async function run(args: string[]): Promise<void> {
const projectRoot = process.cwd();
const { list, limit } = parseArgs(args);
if (list) {
await listGroups(limit);
return;
}
await syncGroups(projectRoot);
}
async function listGroups(limit: number): Promise<void> {
const dbPath = path.join(STORE_DIR, 'messages.db');
if (!fs.existsSync(dbPath)) {
console.error('ERROR: database not found');
process.exit(1);
}
const db = new Database(dbPath, { readonly: true });
const rows = db
.prepare(
`SELECT jid, name FROM chats
WHERE jid LIKE '%@g.us' AND jid <> '__group_sync__' AND name <> jid
ORDER BY last_message_time DESC
LIMIT ?`,
)
.all(limit) as Array<{ jid: string; name: string }>;
db.close();
for (const row of rows) {
console.log(`${row.jid}|${row.name}`);
}
}
async function syncGroups(projectRoot: string): Promise<void> {
// Only WhatsApp needs an upfront group sync; other channels resolve names at runtime.
// Detect WhatsApp by checking for auth credentials on disk.
const authDir = path.join(projectRoot, 'store', 'auth');
const hasWhatsAppAuth =
fs.existsSync(authDir) && fs.readdirSync(authDir).length > 0;
if (!hasWhatsAppAuth) {
log.info('WhatsApp auth not found — skipping group sync');
emitStatus('SYNC_GROUPS', {
BUILD: 'skipped',
SYNC: 'skipped',
GROUPS_IN_DB: 0,
REASON: 'whatsapp_not_configured',
STATUS: 'success',
LOG: 'logs/setup.log',
});
return;
}
// Build TypeScript first
log.info('Building TypeScript');
let buildOk = false;
try {
execSync('pnpm run build', {
cwd: projectRoot,
stdio: ['ignore', 'pipe', 'pipe'],
});
buildOk = true;
log.info('Build succeeded');
} catch {
log.error('Build failed');
emitStatus('SYNC_GROUPS', {
BUILD: 'failed',
SYNC: 'skipped',
GROUPS_IN_DB: 0,
STATUS: 'failed',
ERROR: 'build_failed',
LOG: 'logs/setup.log',
});
process.exit(1);
}
// Run sync script via a temp file to avoid shell escaping issues with node -e
log.info('Fetching group metadata');
let syncOk = false;
try {
const syncScript = `
import makeWASocket, { useMultiFileAuthState, makeCacheableSignalKeyStore, Browsers } from '@whiskeysockets/baileys';
import pino from 'pino';
import path from 'path';
import fs from 'fs';
import Database from 'better-sqlite3';
const logger = pino({ level: 'silent' });
const authDir = path.join('store', 'auth');
const dbPath = path.join('store', 'messages.db');
if (!fs.existsSync(authDir)) {
console.error('NO_AUTH');
process.exit(1);
}
const db = new Database(dbPath);
db.pragma('journal_mode = WAL');
db.exec('CREATE TABLE IF NOT EXISTS chats (jid TEXT PRIMARY KEY, name TEXT, last_message_time TEXT)');
const upsert = db.prepare(
'INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?) ON CONFLICT(jid) DO UPDATE SET name = excluded.name'
);
const { state, saveCreds } = await useMultiFileAuthState(authDir);
const sock = makeWASocket({
auth: { creds: state.creds, keys: makeCacheableSignalKeyStore(state.keys, logger) },
printQRInTerminal: false,
logger,
browser: Browsers.macOS('Chrome'),
});
const timeout = setTimeout(() => {
console.error('TIMEOUT');
process.exit(1);
}, 30000);
sock.ev.on('creds.update', saveCreds);
sock.ev.on('connection.update', async (update) => {
if (update.connection === 'open') {
try {
const groups = await sock.groupFetchAllParticipating();
const now = new Date().toISOString();
let count = 0;
for (const [jid, metadata] of Object.entries(groups)) {
if (metadata.subject) {
upsert.run(jid, metadata.subject, now);
count++;
}
}
console.log('SYNCED:' + count);
} catch (err) {
console.error('FETCH_ERROR:' + err.message);
} finally {
clearTimeout(timeout);
sock.end(undefined);
db.close();
process.exit(0);
}
} else if (update.connection === 'close') {
clearTimeout(timeout);
console.error('CONNECTION_CLOSED');
process.exit(1);
}
});
`;
const tmpScript = path.join(projectRoot, '.tmp-group-sync.mjs');
fs.writeFileSync(tmpScript, syncScript, 'utf-8');
try {
const output = execSync(`node ${tmpScript}`, {
cwd: projectRoot,
encoding: 'utf-8',
timeout: 45000,
stdio: ['ignore', 'pipe', 'pipe'],
});
syncOk = output.includes('SYNCED:');
log.info('Sync output', { output: output.trim() });
} finally {
try { fs.unlinkSync(tmpScript); } catch { /* ignore cleanup errors */ }
}
} catch (err) {
log.error('Sync failed', { err });
}
// Count groups in DB using better-sqlite3 (no sqlite3 CLI)
let groupsInDb = 0;
const dbPath = path.join(STORE_DIR, 'messages.db');
if (fs.existsSync(dbPath)) {
try {
const db = new Database(dbPath, { readonly: true });
const row = db
.prepare(
"SELECT COUNT(*) as count FROM chats WHERE jid LIKE '%@g.us' AND jid <> '__group_sync__'",
)
.get() as { count: number };
groupsInDb = row.count;
db.close();
} catch {
// DB may not exist yet
}
}
const status = syncOk ? 'success' : 'failed';
emitStatus('SYNC_GROUPS', {
BUILD: buildOk ? 'success' : 'failed',
SYNC: syncOk ? 'success' : 'failed',
GROUPS_IN_DB: groupsInDb,
STATUS: status,
LOG: 'logs/setup.log',
});
if (status === 'failed') process.exit(1);
}
+1 -2
View File
@@ -13,9 +13,8 @@ const STEPS: Record<
'set-env': () => import('./set-env.js'),
environment: () => import('./environment.js'),
container: () => import('./container.js'),
groups: () => import('./groups.js'),
register: () => import('./register.js'),
'pair-telegram': () => import('./pair-telegram.js'),
groups: () => import('./groups.js'),
'whatsapp-auth': () => import('./whatsapp-auth.js'),
'signal-auth': () => import('./signal-auth.js'),
mounts: () => import('./mounts.js'),
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Setup helper: install-discord — bundles the preflight + install commands
# from the /add-discord skill into one idempotent script so /setup can
# from the /add-discord skill into one idempotent script so /new-setup can
# run them programmatically before continuing to credentials.
#
# Copies the Discord adapter in from the `channels` branch; appends the
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Setup helper: install-docker — bundles Docker install into one idempotent
# script so /setup can run it without needing `curl | sh` in the allowlist
# script so /new-setup can run it without needing `curl | sh` in the allowlist
# (pipelines split at matching time, and `sh` receiving stdin can't be
# pre-approved safely).
#
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Setup helper: install-gchat — bundles the preflight + install commands
# from the /add-gchat skill into one idempotent script so /setup can
# from the /add-gchat skill into one idempotent script so /new-setup can
# run them programmatically before continuing to credentials.
#
# Copies the Google Chat adapter in from the `channels` branch; appends the
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Setup helper: install-github — bundles the preflight + install commands
# from the /add-github skill into one idempotent script so /setup can
# from the /add-github skill into one idempotent script so /new-setup can
# run them programmatically before continuing to credentials.
#
# Copies the GitHub adapter in from the `channels` branch; appends the
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Setup helper: install-imessage — bundles the preflight + install commands
# from the /add-imessage skill into one idempotent script so /setup can
# from the /add-imessage skill into one idempotent script so /new-setup can
# run them programmatically before continuing to credentials.
#
# Copies the iMessage adapter in from the `channels` branch; appends the
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Setup helper: install-linear — bundles the preflight + install commands
# from the /add-linear skill into one idempotent script so /setup can
# from the /add-linear skill into one idempotent script so /new-setup can
# run them programmatically before continuing to credentials.
#
# Copies the Linear adapter in from the `channels` branch; appends the
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Setup helper: install-matrix — bundles the preflight + install commands
# from the /add-matrix skill into one idempotent script so /setup can
# from the /add-matrix skill into one idempotent script so /new-setup can
# run them programmatically before continuing to credentials.
#
# Copies the Matrix adapter in from the `channels` branch; appends the
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Setup helper: install-node — bundles Node 22 install into one idempotent
# script so /setup can run it without needing `curl | sudo -E bash -` in
# script so /new-setup can run it without needing `curl | sudo -E bash -` in
# the allowlist (that pattern is inherently unmatchable — bash reads from
# stdin, so pre-approval can't inspect what's being executed).
#
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Setup helper: install-resend — bundles the preflight + install commands
# from the /add-resend skill into one idempotent script so /setup can
# from the /add-resend skill into one idempotent script so /new-setup can
# run them programmatically before continuing to credentials.
#
# Copies the Resend adapter in from the `channels` branch; appends the
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Setup helper: install-slack — bundles the preflight + install commands
# from the /add-slack skill into one idempotent script so /setup can
# from the /add-slack skill into one idempotent script so /new-setup can
# run them programmatically before continuing to credentials.
#
# Copies the Slack adapter in from the `channels` branch; appends the
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Setup helper: install-teams — bundles the preflight + install commands
# from the /add-teams skill into one idempotent script so /setup can
# from the /add-teams skill into one idempotent script so /new-setup can
# run them programmatically before continuing to credentials.
#
# Copies the Teams adapter in from the `channels` branch; appends the
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Setup helper: install-telegram — bundles the preflight + install commands
# from the /add-telegram skill into one idempotent script so /setup can
# from the /add-telegram skill into one idempotent script so /new-setup can
# run them programmatically before continuing to credentials and pairing.
#
# Copies the Telegram adapter, helpers, tests, and the pair-telegram setup
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Setup helper: install-webex — bundles the preflight + install commands
# from the /add-webex skill into one idempotent script so /setup can
# from the /add-webex skill into one idempotent script so /new-setup can
# run them programmatically before continuing to credentials.
#
# Copies the Webex adapter in from the `channels` branch; appends the
+1 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# Setup helper: install-whatsapp-cloud — bundles the preflight + install
# commands from the /add-whatsapp-cloud skill into one idempotent script so
# /setup can run them programmatically before continuing to credentials.
# /new-setup can run them programmatically before continuing to credentials.
#
# Copies the WhatsApp Cloud adapter in from the `channels` branch; appends the
# self-registration import; installs the pinned @chat-adapter/whatsapp package;
+2 -2
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Setup helper: install-whatsapp — bundles the preflight + install commands
# from the /add-whatsapp skill into one idempotent script so /setup can
# from the /add-whatsapp skill into one idempotent script so /new-setup can
# run them programmatically before continuing to QR/pairing-code auth.
#
# Copies the native Baileys WhatsApp adapter, its whatsapp-auth and groups
@@ -66,7 +66,7 @@ if ! grep -q "'whatsapp-auth':" setup/index.ts; then
fi
echo "STEP: pnpm-install"
pnpm install @whiskeysockets/baileys@7.0.0-rc.9 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0
pnpm install @whiskeysockets/baileys@6.17.16 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0
echo "STEP: pnpm-build"
pnpm run build
+1 -1
View File
@@ -1,6 +1,6 @@
#!/bin/bash
# Setup step: probe — single upfront parallel-ish scan that snapshots every
# prerequisite and dependency for /setup's dynamic context injection.
# prerequisite and dependency for /new-setup's dynamic context injection.
# Rendered into the SKILL.md prompt via `!bash setup/probe.sh` so Claude sees
# the current system state before generating its first response.
#
@@ -0,0 +1,22 @@
/**
* Setup-side registration guard for the codex provider (the third barrel of
* the multi-point archetype): imports the REAL setup/providers barrel and
* asserts the registry carries codex with its auth + install check. Red if
* the barrel line is deleted, the barrel fails to evaluate, or the payload
* module breaks. (Importing ./codex.js directly would self-register and stay
* green when the barrel line is deleted.)
*/
import { describe, expect, it } from 'vitest';
import { getSetupProvider } from './registry.js';
import './index.js'; // the real setup provider barrel
describe('codex setup registration', () => {
it('registers codex with auth + install check via the barrel', () => {
const codex = getSetupProvider('codex');
expect(codex).toBeDefined();
expect(typeof codex!.runAuth).toBe('function');
expect(typeof codex!.runInstallCheck).toBe('function');
expect(typeof codex!.offerFailureAssist).toBe('function');
});
});
+101
View File
@@ -0,0 +1,101 @@
import { EventEmitter } from 'events';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { describe, expect, it, vi } from 'vitest';
// Mock child_process so runCodexLoginAuth never spawns a real codex CLI; the
// spawn stand-in plays `codex login` writing auth.json into whatever
// CODEX_HOME it was handed.
const mockSpawn = vi.fn();
const mockSpawnSync = vi.fn();
const mockExecFileSync = vi.fn();
vi.mock('child_process', () => ({
spawn: (...args: unknown[]) => mockSpawn(...args),
spawnSync: (...args: unknown[]) => mockSpawnSync(...args),
execFileSync: (...args: unknown[]) => mockExecFileSync(...args),
}));
// Keep the auth flow's structured logging out of logs/setup.log.
vi.mock('../logs.js', () => ({ step: vi.fn(), userInput: vi.fn() }));
import { buildCodexFailurePrompt, runCodexLoginAuth, verifyCodexInstall } from './codex.js';
// Structural guard for the codex payload wiring: provider files, both barrel
// imports, and the pinned Dockerfile install. Goes red if any of them is
// removed without going through the /add-codex (or its REMOVE.md) path.
describe('verifyCodexInstall', () => {
it('passes on a tree with the codex payload wired', () => {
const { ok, problems } = verifyCodexInstall();
expect(problems).toEqual([]);
expect(ok).toBe(true);
});
});
// Pure prompt builder for the failure-assist hook — no spawning involved.
describe('buildCodexFailurePrompt', () => {
it('carries the failure context and the de-duped reference list', () => {
const projectRoot = '/repo';
const prompt = buildCodexFailurePrompt(
{
stepName: 'verify',
msg: 'first-chat ping timed out',
hint: 'check the container logs',
rawLogPath: '/repo/logs/setup-steps/verify.log',
},
projectRoot,
);
expect(prompt).toContain('Failed step: verify');
expect(prompt).toContain('Error: first-chat ping timed out');
expect(prompt).toContain('Hint: check the container logs');
expect(prompt).toContain('README.md'); // BIG_PICTURE_FILES
expect(prompt).toContain('setup/verify.ts'); // STEP_FILES['verify']
expect(prompt).toContain('logs/setup.log');
expect(prompt).toContain('logs/setup-steps/verify.log'); // relativized rawLogPath
});
it('falls back to the step-log directory when no raw log path is given', () => {
const prompt = buildCodexFailurePrompt({ stepName: 'verify', msg: 'boom' }, '/repo');
expect(prompt).toContain('logs/setup-steps/');
expect(prompt).not.toContain('Hint:');
});
});
// Session-isolation invariant: the ChatGPT session vaulted for the gateway
// must never be the user's personal ~/.codex session — sharing one OAuth
// session across two consumers gets the whole family invalidated server-side
// when refresh tokens rotate (see the header of codex.ts).
describe('runCodexLoginAuth', () => {
it('logs in under an isolated CODEX_HOME, vaults from it, and deletes it', async () => {
mockSpawnSync.mockReturnValue({ status: 0, stdout: '', stderr: '' });
mockExecFileSync.mockReturnValue('');
let loginEnv: NodeJS.ProcessEnv | undefined;
mockSpawn.mockImplementation((...args: unknown[]) => {
const opts = args[2] as { env?: NodeJS.ProcessEnv };
loginEnv = opts.env;
fs.writeFileSync(path.join(opts.env!.CODEX_HOME!, 'auth.json'), '{"tokens":{}}');
const child = new EventEmitter();
setImmediate(() => child.emit('close', 0));
return child;
});
await runCodexLoginAuth('browser');
// The login spawn ran under a CODEX_HOME that is not the personal one.
const codexHome = loginEnv?.CODEX_HOME;
expect(codexHome).toBeDefined();
expect(codexHome).not.toBe(path.join(os.homedir(), '.codex'));
// The vault snapshot was read from the isolated dir, not ~/.codex.
const vaultCall = mockExecFileSync.mock.calls.find((c) => c[0] === 'onecli');
expect(vaultCall).toBeDefined();
const vaultArgs = vaultCall![1] as string[];
expect(vaultArgs[vaultArgs.indexOf('--file') + 1]).toBe(path.join(codexHome!, 'auth.json'));
// The isolated dir holds a live credential — gone once vaulted.
expect(fs.existsSync(codexHome!)).toBe(false);
});
});
+449
View File
@@ -0,0 +1,449 @@
/**
* Codex provider setup auth walk-through + install verification.
*
* Codex-owned payload code: when the codex provider moves to the `providers`
* branch, this file travels with it and `/add-codex` copies it back in. The
* only trunk reach-in is one import + one picker entry in setup/auto.ts.
*
* Auth honors the v2 credential invariant everything lands in the OneCLI
* vault, nothing in .env, nothing in the container:
* - ChatGPT subscription (the common case): `codex login` (browser) or
* `codex login --device-auth` (URL + pairing code) runs with CODEX_HOME
* pointed at a throwaway dir; the auth.json written there is stored
* WHOLE in the vault (`--file … --host-pattern chatgpt.com`) and the dir
* is deleted. The gateway injects it in flight; the container only ever
* sees the `onecli-managed` placeholder.
* - API key: pasted once, stored as an `openai` secret for api.openai.com.
*
* Session-isolation invariant: the vaulted ChatGPT session must be DEDICATED
* to the gateway. Never vault a copy of the user's live ~/.codex/auth.json.
* OpenAI rotates refresh tokens, so two consumers sharing one OAuth session
* strand each other on refresh, and replaying the stale token trips reuse
* detection which invalidates the whole session family server-side
* (`token_invalidated`) for the gateway AND the user's personal Codex CLI.
*/
import { execFileSync, spawn, spawnSync } from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
import * as p from '@clack/prompts';
import k from 'kleur';
import { brightSelect } from '../lib/bright-select.js';
import { type AssistContext, BIG_PICTURE_FILES, STEP_FILES } from '../lib/claude-assist.js';
import { brandBody, note } from '../lib/theme.js';
import * as setupLog from '../logs.js';
import { type FailureAssistResult, registerSetupProvider } from './registry.js';
// ─── OneCLI vault helpers ────────────────────────────────────────────────
interface OnecliSecret {
id: string;
name: string;
type: string;
hostPattern: string | null;
}
function listSecrets(): OnecliSecret[] {
const out = execFileSync('onecli', ['secrets', 'list'], { encoding: 'utf-8' });
const parsed = JSON.parse(out) as { data?: unknown };
return Array.isArray(parsed.data) ? (parsed.data as OnecliSecret[]) : [];
}
function findOpenAISecret(secrets: OnecliSecret[]): OnecliSecret | undefined {
return secrets.find((s) => {
const name = s.name.toLowerCase();
const type = s.type.toLowerCase();
const hostPattern = (s.hostPattern ?? '').toLowerCase();
return (
name === 'codex' ||
name === 'openai' ||
type === 'openai' ||
hostPattern.includes('api.openai.com') ||
hostPattern.includes('chatgpt.com')
);
});
}
function openAISecretExists(): boolean {
try {
return findOpenAISecret(listSecrets()) !== undefined;
} catch {
return false;
}
}
// ─── auth step ───────────────────────────────────────────────────────────
function ensureAnswer<T>(value: T | symbol): T {
if (p.isCancel(value)) {
p.cancel('Setup cancelled.');
process.exit(1);
}
return value as T;
}
export async function runCodexAuthStep(): Promise<void> {
if (openAISecretExists()) {
p.log.success(brandBody('Your OpenAI account is already connected.'));
setupLog.step('auth', 'skipped', 0, { REASON: 'openai-secret-already-present', PROVIDER: 'codex' });
return;
}
const method = ensureAnswer(
await brightSelect<'browser' | 'device' | 'api' | 'skip'>({
message: 'How would you like to connect Codex?',
options: [
{
value: 'browser',
label: 'Sign in with my ChatGPT subscription',
hint: 'recommended if you have Plus or Pro — opens a browser',
},
{
value: 'device',
label: 'ChatGPT device pairing',
hint: 'no browser handoff — shows a URL and a code',
},
{
value: 'api',
label: 'Paste an OpenAI API key',
hint: 'pay-per-use; stored in OneCLI, never copied into the container',
},
{
value: 'skip',
label: "Skip — I'll connect later",
hint: 'Codex groups will start, but model calls will fail auth',
},
],
}),
);
setupLog.userInput('codex_auth_method', method);
if (method === 'skip') {
const confirmed = ensureAnswer(
await p.confirm({
message: "Skip Codex sign-in? Codex won't be able to answer until you connect an OpenAI account.",
initialValue: false,
}),
);
if (!confirmed) return runCodexAuthStep();
setupLog.step('auth', 'skipped', 0, { REASON: 'user-skipped', PROVIDER: 'codex' });
p.log.warn(brandBody('Codex sign-in skipped. Add an OpenAI account to OneCLI before using Codex groups.'));
return;
}
if (method === 'api') {
await runCodexApiKeyAuth();
return;
}
await runCodexLoginAuth(method);
}
async function runCodexApiKeyAuth(): Promise<void> {
const key = ensureAnswer(
await p.password({
message: 'Paste your OpenAI API key (sk-…)',
validate: (v) => (v && v.trim().startsWith('sk-') ? undefined : 'That does not look like an OpenAI API key.'),
}),
) as string;
try {
execFileSync(
'onecli',
[
'secrets',
'create',
'--name',
'Codex',
'--type',
'openai',
'--value',
key.trim(),
'--host-pattern',
'api.openai.com',
],
{ stdio: ['ignore', 'pipe', 'pipe'] },
);
} catch (err) {
setupLog.step('auth', 'failed', 0, { PROVIDER: 'codex', METHOD: 'api', ERROR: String(err) });
p.log.error(
brandBody(
"Couldn't save your OpenAI key to the vault. Make sure OneCLI is running (`onecli version`), then retry.",
),
);
process.exit(1);
}
setupLog.step('auth', 'success', 0, { PROVIDER: 'codex', METHOD: 'api' });
p.log.success(brandBody('OpenAI account connected.'));
}
export async function runCodexLoginAuth(method: 'browser' | 'device'): Promise<void> {
const codexCheck = spawnSync('codex', ['--version'], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
if (codexCheck.status !== 0) {
p.log.error(
brandBody(
'The Codex CLI is not installed on this machine. Install it with `npm install -g @openai/codex`, then re-run setup — or choose the API key option instead.',
),
);
setupLog.step('auth', 'failed', 0, { PROVIDER: 'codex', METHOD: method, ERROR: 'codex_cli_missing' });
process.exit(1);
}
if (method === 'browser') {
p.log.step(brandBody('Opening the Codex sign-in flow…'));
console.log(k.dim(' (a browser will open for sign-in; this part is interactive)'));
} else {
p.log.step(brandBody('Starting Codex device-code pairing…'));
console.log(k.dim(' (a URL and code will appear below — open the URL and enter the code)'));
}
console.log();
// Session-isolation invariant (see file header): the login runs under a
// throwaway CODEX_HOME so the vaulted session is dedicated to the gateway
// and never shared with the user's personal ~/.codex.
const loginHome = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-vault-login-'));
// Holds a live credential after login — must go on every exit path. The
// failure branches call process.exit, which skips finally blocks, so each
// removes it explicitly.
const removeLoginHome = (): void => fs.rmSync(loginHome, { recursive: true, force: true });
const args = method === 'device' ? ['login', '--device-auth'] : ['login'];
const start = Date.now();
const code = await runInherit('codex', args, { CODEX_HOME: loginHome });
const durationMs = Date.now() - start;
console.log();
if (code !== 0) {
removeLoginHome();
setupLog.step('auth', 'failed', durationMs, { PROVIDER: 'codex', METHOD: method, EXIT_CODE: String(code) });
p.log.error(
brandBody(
"Couldn't complete the Codex sign-in. Re-run setup and try again, or choose the API key option instead.",
),
);
process.exit(1);
}
const authJsonPath = path.join(loginHome, 'auth.json');
if (!fs.existsSync(authJsonPath)) {
removeLoginHome();
setupLog.step('auth', 'failed', durationMs, { PROVIDER: 'codex', METHOD: method, ERROR: 'auth_json_not_found' });
p.log.error(
brandBody('Codex login succeeded but no auth.json was written. Try again, or paste an API key instead.'),
);
process.exit(1);
}
try {
execFileSync(
'onecli',
[
'secrets',
'create',
'--name',
'Codex',
'--type',
'openai',
'--file',
authJsonPath,
'--host-pattern',
'chatgpt.com',
],
{ stdio: ['ignore', 'pipe', 'pipe'] },
);
} catch (err) {
removeLoginHome();
setupLog.step('auth', 'failed', durationMs, { PROVIDER: 'codex', METHOD: method, ERROR: String(err) });
p.log.error(
brandBody(
"Couldn't save your Codex credentials to the vault. Make sure OneCLI is running (`onecli version`), then retry.",
),
);
process.exit(1);
}
removeLoginHome();
setupLog.step('auth', 'success', durationMs, { PROVIDER: 'codex', METHOD: method });
p.log.success(brandBody('OpenAI account connected — credentials live in your OneCLI vault, never in the container.'));
}
function runInherit(cmd: string, args: string[], extraEnv?: Record<string, string>): Promise<number> {
return new Promise((resolve) => {
const child = spawn(cmd, args, {
stdio: 'inherit',
env: extraEnv ? { ...process.env, ...extraEnv } : process.env,
});
child.on('close', (code) => resolve(code ?? 1));
child.on('error', () => resolve(1));
});
}
// ─── failure assist ──────────────────────────────────────────────────────
/**
* The Codex CLI can debug a setup failure only if the binary runs AND
* ~/.codex/auth.json exists (API-key-only installs keep the key in the
* OneCLI vault, so the host-side CLI has nothing to authenticate with).
*/
export function isCodexCliUsable(): boolean {
const codexCheck = spawnSync('codex', ['--version'], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
if (codexCheck.status !== 0) return false;
return fs.existsSync(path.join(os.homedir(), '.codex', 'auth.json'));
}
/**
* Failure prompt handed to the interactive Codex session same content as
* the dispatcher's Claude system prompt: what failed, the job ("diagnose and
* fix, be concise, exit when done"), and a de-duped file reference list.
*/
export function buildCodexFailurePrompt(ctx: AssistContext, projectRoot: string): string {
const stepRefs = STEP_FILES[ctx.stepName] ?? [];
const references = [
...BIG_PICTURE_FILES,
...stepRefs,
'logs/setup.log',
ctx.rawLogPath ? path.relative(projectRoot, ctx.rawLogPath) : 'logs/setup-steps/',
].filter((v, i, a) => a.indexOf(v) === i);
const lines: string[] = [
"The user is running NanoClaw's interactive setup flow and hit a failure.",
'',
`Failed step: ${ctx.stepName}`,
`Error: ${ctx.msg}`,
];
if (ctx.hint) lines.push(`Hint: ${ctx.hint}`);
lines.push(
'',
'Your job: help them diagnose and fix this issue. Read the referenced files',
'and logs to understand what went wrong, then help them fix it. You can read',
'files, run commands, check logs, and explain what happened. Be concise.',
"When they're ready to resume setup, tell them to exit Codex.",
'',
'Relevant files (read as needed):',
);
for (const f of references) lines.push(` - ${f}`);
return lines.join('\n');
}
/**
* Registry hook: offer to debug a setup failure with the Codex CLI. Returns
* 'unavailable' when the CLI can't run here so the dispatcher can fall back
* to its guarded Claude offer.
*/
export async function offerCodexFailureAssist(ctx: AssistContext, projectRoot: string): Promise<FailureAssistResult> {
if (!isCodexCliUsable()) return 'unavailable';
const want = ensureAnswer(
await p.confirm({
message: 'Want to debug this with Codex?',
initialValue: true,
}),
);
if (!want) return 'declined';
const prompt = buildCodexFailurePrompt(ctx, projectRoot);
note(
[
'Launching Codex to help debug this failure.',
'It has the context of what went wrong.',
'',
k.dim("Exit Codex (Ctrl-C or /quit) when you're ready to come back to setup."),
].join('\n'),
'Handing off to Codex',
);
return new Promise<FailureAssistResult>((resolve) => {
// codex accepts a positional initial prompt for the interactive TUI.
const child = spawn('codex', [prompt], { cwd: projectRoot, stdio: 'inherit' });
child.on('close', () => {
p.log.success(brandBody("Back from Codex. Let's continue."));
resolve('launched');
});
child.on('error', () => {
p.log.error("Couldn't launch Codex.");
resolve('unavailable');
});
});
}
// ─── install verification ────────────────────────────────────────────────
/**
* Verify the codex provider payload is fully wired the same pre-flight the
* /add-codex skill checks. While codex ships in trunk these always pass; once
* the payload moves to the providers branch, a failed check means the install
* step should run (or the user finishes via /add-codex).
*/
export function verifyCodexInstall(): { ok: boolean; problems: string[] } {
const problems: string[] = [];
const root = process.cwd();
const requiredFiles = [
'src/providers/codex.ts',
'src/providers/codex-agents-md.ts',
'container/agent-runner/src/providers/codex.ts',
'container/agent-runner/src/providers/codex-app-server.ts',
];
for (const file of requiredFiles) {
if (!fs.existsSync(path.join(root, file))) problems.push(`missing file: ${file}`);
}
for (const barrel of ['src/providers/index.ts', 'container/agent-runner/src/providers/index.ts']) {
const barrelPath = path.join(root, barrel);
if (!fs.existsSync(barrelPath) || !fs.readFileSync(barrelPath, 'utf-8').includes("import './codex.js';")) {
problems.push(`missing barrel import in ${barrel}`);
}
}
const manifestPath = path.join(root, 'container', 'cli-tools.json');
let hasCodexCli = false;
if (fs.existsSync(manifestPath)) {
try {
const tools = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as Array<{ name?: string }>;
hasCodexCli = Array.isArray(tools) && tools.some((t) => t.name === '@openai/codex');
} catch {
hasCodexCli = false;
}
}
if (!hasCodexCli) {
problems.push('container/cli-tools.json missing the @openai/codex CLI entry');
}
return { ok: problems.length === 0, problems };
}
export async function runCodexInstallCheck(): Promise<void> {
p.log.step(brandBody('Checking the Codex provider install…'));
const { ok, problems } = verifyCodexInstall();
if (ok) {
setupLog.step('codex-install', 'success', 0, {});
p.log.success(brandBody('Codex installed properly.'));
return;
}
setupLog.step('codex-install', 'failed', 0, { PROBLEMS: problems.join('; ') });
p.log.warn(brandBody('The Codex provider is not fully installed:'));
for (const problem of problems) console.log(k.dim(`${problem}`));
p.log.warn(
brandBody(
'Finish it with your coding agent of choice: open Codex CLI or Claude Code in this repo and run the /add-codex skill. Setup will continue — Codex groups will work once the install completes.',
),
);
}
// Self-registration: the setup picker and the standalone `provider-auth` step
// render from the registry — this call is codex's only reach-in to the setup
// flow (guarded by the barrel-driven registration test).
registerSetupProvider({
value: 'codex',
label: 'Codex',
hint: 'OpenAI — ChatGPT subscription or API key',
runAuth: runCodexAuthStep,
runInstallCheck: runCodexInstallCheck,
offerFailureAssist: offerCodexFailureAssist,
});
+25 -19
View File
@@ -1,5 +1,5 @@
/**
* Step: whatsapp-auth standalone WhatsApp (Baileys v7) authentication.
* Step: whatsapp-auth standalone WhatsApp (Baileys) authentication.
*
* Forked from the channels-branch version so setup:auto's driver can render
* the terminal UX itself (inside clack) instead of the step dumping a raw QR
@@ -27,6 +27,7 @@
*/
import fs from 'fs';
import path from 'path';
import { createRequire } from 'module';
// Named import (not default) — pino's d.ts under NodeNext resolves the
// default export to `typeof pino` (namespace), which isn't callable. The
// named `pino` export resolves to the callable function.
@@ -46,23 +47,26 @@ const AUTH_DIR = path.join(process.cwd(), 'store', 'auth');
const PAIRING_CODE_FILE = path.join(process.cwd(), 'store', 'pairing-code.txt');
const baileysLogger = pino({ level: 'silent' });
/** Fetch current WA Web version — wppconnect tracker, then Baileys sw.js scrape. */
async function resolveWaWebVersion(): Promise<[number, number, number]> {
try {
const res = await fetch('https://wppconnect.io/whatsapp-versions/', {
signal: AbortSignal.timeout(5000),
});
if (res.ok) {
const html = await res.text();
const match = html.match(/2\.3000\.(\d+)/);
if (match) return [2, 3000, Number(match[1])];
}
} catch { /* fall through */ }
try {
const { version } = await fetchLatestWaWebVersion({});
if (version) return version as [number, number, number];
} catch { /* fall through */ }
throw new Error('Could not fetch current WhatsApp Web version — cannot connect with stale version');
// Baileys v6 bug: getPlatformId sends charCode (49) instead of enum value (1).
// Fixed in Baileys 7.x but not backported. Without this patch pairing codes
// fail with "couldn't link device" because WhatsApp receives an invalid
// platform id. createRequire because proto is not a named ESM export.
const _require = createRequire(import.meta.url);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { proto } = _require('@whiskeysockets/baileys') as { proto: any };
try {
const _generics = _require(
'@whiskeysockets/baileys/lib/Utils/generics',
) as Record<string, unknown>;
_generics.getPlatformId = (browser: string): string => {
const platformType =
proto.DeviceProps.PlatformType[
browser.toUpperCase() as keyof typeof proto.DeviceProps.PlatformType
];
return platformType ? platformType.toString() : '1';
};
} catch {
// If CJS require fails, QR auth still works; only pairing code may be affected.
}
type AuthMethod = 'qr' | 'pairing-code';
@@ -135,7 +139,9 @@ export async function run(args: string[]): Promise<void> {
async function connectSocket(isReconnect = false): Promise<void> {
const { state, saveCreds } = await useMultiFileAuthState(AUTH_DIR);
const version = await resolveWaWebVersion();
const { version } = await fetchLatestWaWebVersion({}).catch(() => ({
version: undefined,
}));
const sock = makeWASocket({
version,
+1 -80
View File
@@ -80,8 +80,7 @@ export interface InboundMessage {
* display name (e.g. `@Andy`).
*
* Adapters that don't set it (native / legacy) leave it undefined the
* router treats undefined as "not a mention" (`isMention === true` check,
* src/router.ts). There is no text-match fallback.
* router falls back to text-match against agent_group_name.
*/
isMention?: boolean;
/** True when the source is a group/channel thread, false for DMs. */
@@ -108,71 +107,11 @@ export interface ConversationInfo {
isGroup: boolean;
}
/** Wiring/mg defaults for one conversation context (DM vs group/channel). */
export interface ChannelContextDefaults {
/** Default engage_mode for wirings created in this context. */
engageMode: 'pattern' | 'mention' | 'mention-sticky';
/**
* Default engage_pattern when engageMode === 'pattern'. May contain the
* literal token `{name}`: creation helpers replace it with the regex-escaped
* agent_group name (for platforms with no group-mention metadata, e.g.
* iMessage/DeltaChat groups, WhatsApp shared-number mode). Required iff
* engageMode === 'pattern'.
*/
engagePattern?: string;
/**
* Whether thread ids are honored in this context by default.
* true inbound thread ids flow into messages_in and (in groups) force
* per-thread session identity; replies, typing, and cards land
* in-thread.
* false thread ids are nulled per-wiring at router fanout; sessions
* collapse; replies land top-level.
* MUST be false when `supportsThreads` is false (capability bound; the
* router treats supportsThreads=false as a hard pre-strip regardless).
* Per-wiring override: messaging_group_agents.threads (NULL = inherit).
*/
threads: boolean;
/**
* unknown_sender_policy stamped on messaging_groups rows auto-created by
* the router or created by wizard/CLI paths in this context.
*/
unknownSenderPolicy: 'strict' | 'request_approval' | 'public';
}
/**
* Static per-channel declaration of wiring-time defaults. Exactly two levels
* exist: this declaration, and the per-wiring/per-mg values chosen at
* creation. Install-wide changes = edit the adapter copy (skill-installed,
* user-owned). Never persisted to the central DB.
*/
export interface ChannelDefaults {
dm: ChannelContextDefaults;
group: ChannelContextDefaults;
/**
* Which mention signal the adapter emits (InboundMessage.isMention):
* 'platform' platform-confirmed mentions in groups; DMs flagged too.
* 'dm-only' only DMs flagged (no group mention metadata).
* 'never' isMention never set: auto-create/registration card never
* fires; 'mention'/'mention-sticky' wirings never engage.
* Creation surfaces must reject/warn on mention modes that can never fire.
*/
mentions: 'platform' | 'dm-only' | 'never';
}
/** The v2 channel adapter contract. */
export interface ChannelAdapter {
name: string;
channelType: string;
/**
* Adapter-instance name distinguishes N adapters of one platform
* (e.g. three Slack apps in one workspace). Defaults to channelType.
* channelType stays the SEMANTIC platform key (user ids '<channelType>:<handle>',
* formatting, container config); instance is a host-side routing key only.
* Must be unique across active adapters and URL-safe (no '/', '?', ':').
*/
instance?: string;
/**
* Whether this adapter models conversations as threads.
*
@@ -224,16 +163,6 @@ export interface ChannelAdapter {
* Returning the same platform_id on repeated calls is expected.
*/
openDM?(userHandle: string): Promise<string>;
resolveChannelName?: (platformId: string) => Promise<string | null>;
/**
* Declared wiring-time defaults for this channel. Optional for backward
* compatibility with stale adapter copies; absent core fallback
* (fallbackChannelDefaults(supportsThreads), see channel-registry.ts).
* May be computed from adapter-internal env at module load (e.g. WhatsApp
* shared-number mode), but is immutable for the process lifetime.
*/
defaults?: ChannelDefaults;
}
/** Factory function that creates a channel adapter (returns null if credentials missing). */
@@ -242,14 +171,6 @@ export type ChannelAdapterFactory = () => ChannelAdapter | Promise<ChannelAdapte
/** Registration entry for a channel adapter. */
export interface ChannelRegistration {
factory: ChannelAdapterFactory;
/**
* Same declaration as ChannelAdapter.defaults, resolvable WITHOUT
* instantiating the adapter offline creation paths (setup/register.ts,
* scripts/init-first-agent.ts, ncl against a host where the factory
* returned null for missing creds) read it from the registry. Channel
* modules pass the same const here and to the adapter/bridge.
*/
defaults?: ChannelDefaults;
containerConfig?: {
mounts?: Array<{ hostPath: string; containerPath: string; readonly: boolean }>;
env?: Record<string, string>;
-104
View File
@@ -1,104 +0,0 @@
/**
* Guards the ChannelDefaults declarations every channel module registers.
*
* Each module is imported directly (not via the barrel many imports there
* are commented out until the corresponding /add-<channel> skill installs
* them). Importing runs the top-level registerChannelAdapter(name, { ,
* defaults }) call; factories are never invoked, so getChannelDefaults
* resolves from the registration tier.
*
* Two exclusions, both import-time-only (typecheck still covers them):
* - deltachat: its runtime dep (@deltachat/stdio-rpc-server) is
* skill-installed and absent from this branch's package.json.
* - matrix: @beeper/chat-adapter-matrix's dist has an extensionless ESM
* import (matrix-js-sdk/lib/http-api/errors) that Node/vitest can't
* resolve, so the module can't be evaluated in this environment.
*/
import { describe, it, expect } from 'vitest';
import type { ChannelDefaults } from './adapter.js';
import { getChannelDefaults } from './channel-registry.js';
import './cli.js';
import './discord.js';
import './slack.js';
import './telegram.js';
import './github.js';
import './linear.js';
import './gchat.js';
import './teams.js';
import './whatsapp-cloud.js';
import './resend.js';
import './webex.js';
import './imessage.js';
import './whatsapp.js';
import './signal.js';
import './emacs.js';
import './wechat.js';
/** channel → key facts of its declaration (the parts that differ per channel). */
const EXPECTED: Record<
string,
{ groupMode: ChannelDefaults['group']['engageMode']; groupThreads: boolean; mentions: ChannelDefaults['mentions'] }
> = {
cli: { groupMode: 'pattern', groupThreads: false, mentions: 'never' },
discord: { groupMode: 'mention-sticky', groupThreads: true, mentions: 'platform' },
slack: { groupMode: 'mention-sticky', groupThreads: true, mentions: 'platform' },
telegram: { groupMode: 'mention', groupThreads: false, mentions: 'platform' },
github: { groupMode: 'mention', groupThreads: true, mentions: 'platform' },
linear: { groupMode: 'pattern', groupThreads: true, mentions: 'never' },
gchat: { groupMode: 'mention', groupThreads: true, mentions: 'platform' },
teams: { groupMode: 'mention', groupThreads: true, mentions: 'platform' },
'whatsapp-cloud': { groupMode: 'mention', groupThreads: false, mentions: 'platform' },
resend: { groupMode: 'pattern', groupThreads: false, mentions: 'dm-only' },
webex: { groupMode: 'mention', groupThreads: true, mentions: 'platform' },
imessage: { groupMode: 'pattern', groupThreads: false, mentions: 'dm-only' },
// whatsapp is env-computed; the test env has no ASSISTANT_HAS_OWN_NUMBER=true
// so the shared-number declaration applies. (Dedicated mode is covered by
// the adapter's own tests once PR8 lands the behavior split.)
whatsapp: { groupMode: 'pattern', groupThreads: false, mentions: 'never' },
// signal emits top-level isGroup/isMention (DM→true, group→account tagged);
// non-threaded, so group mode is 'mention', never sticky.
signal: { groupMode: 'mention', groupThreads: false, mentions: 'platform' },
emacs: { groupMode: 'pattern', groupThreads: false, mentions: 'never' },
// wechat emits isMention only for DMs (shared account, no group-mention
// metadata), so mention wirings are dm-only and groups stay name-pattern.
wechat: { groupMode: 'pattern', groupThreads: false, mentions: 'dm-only' },
};
describe('channel default declarations', () => {
for (const [channel, expected] of Object.entries(EXPECTED)) {
describe(channel, () => {
const decl = getChannelDefaults(channel);
it('declares the expected group mode, threads, and mention capability', () => {
expect(decl.group.engageMode).toBe(expected.groupMode);
expect(decl.group.threads).toBe(expected.groupThreads);
expect(decl.mentions).toBe(expected.mentions);
});
it('declares a valid pattern default in every pattern-mode context', () => {
for (const ctx of [decl.dm, decl.group]) {
if (ctx.engageMode === 'pattern') {
expect(ctx.engagePattern).toBeTruthy();
// Must compile once {name} is substituted (resolveWiringDefaults
// regex-escapes the name, so any literal stands in).
expect(() => new RegExp(ctx.engagePattern!.replaceAll('{name}', 'Agent'))).not.toThrow();
}
}
});
it('never declares a mention mode the channel says can never fire', () => {
if (expected.mentions === 'never') {
expect(decl.dm.engageMode).toBe('pattern');
expect(decl.group.engageMode).toBe('pattern');
}
});
it('DMs engage on every message', () => {
expect(decl.dm.engageMode).toBe('pattern');
expect(decl.dm.engagePattern).toBe('.');
});
});
}
});
-273
View File
@@ -1,273 +0,0 @@
/**
* Tests for channel default declarations: getChannelDefaults tiered lookup,
* the behavior-faithful fallback, and the wiring-creation helpers.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import type { ChannelAdapter, ChannelDefaults, ChannelSetup } from './adapter.js';
function makeDefaults(marker: string, threads = true): ChannelDefaults {
return {
dm: { engageMode: 'pattern', engagePattern: marker, threads, unknownSenderPolicy: 'public' },
group: { engageMode: 'mention', threads, unknownSenderPolicy: 'strict' },
mentions: 'platform',
};
}
function makeAdapter(
channelType: string,
opts: { instance?: string; supportsThreads?: boolean; defaults?: ChannelDefaults } = {},
): ChannelAdapter {
return {
name: opts.instance ?? channelType,
channelType,
instance: opts.instance,
supportsThreads: opts.supportsThreads ?? false,
defaults: opts.defaults,
async setup(_config: ChannelSetup) {},
async teardown() {},
isConnected: () => true,
async deliver() {
return undefined;
},
};
}
const mockSetup = () => ({
onInbound: () => {},
onInboundEvent: () => {},
onMetadata: () => {},
onAction: () => {},
});
describe('getChannelDefaults — tiered lookup', () => {
// The registry and activeAdapters maps are module-level; fresh module per
// test so registrations don't leak across arms.
beforeEach(() => {
vi.resetModules();
});
afterEach(async () => {
const { teardownChannelAdapters } = await import('./channel-registry.js');
await teardownChannelAdapters();
vi.resetModules();
});
it('live adapter declaration wins over the registration declaration', async () => {
const reg = await import('./channel-registry.js');
const liveDecl = makeDefaults('live');
const regDecl = makeDefaults('registration');
reg.registerChannelAdapter('mock', {
factory: () => makeAdapter('mock', { defaults: liveDecl }),
defaults: regDecl,
});
await reg.initChannelAdapters(mockSetup);
expect(reg.getChannelDefaults('mock')).toBe(liveDecl);
});
it('falls through a live channelType scan for a channelType key (live tier instance→channelType)', async () => {
const reg = await import('./channel-registry.js');
const decl = makeDefaults('named-instance');
reg.registerChannelAdapter('slack-tester', {
factory: () => makeAdapter('slack', { instance: 'slack-tester', defaults: decl }),
});
await reg.initChannelAdapters(mockSetup);
// Key is the bare channelType; only a named instance is live.
expect(reg.getChannelDefaults('slack')).toBe(decl);
});
it('falls through to the registration entry when the factory returned null', async () => {
const reg = await import('./channel-registry.js');
const decl = makeDefaults('registration');
reg.registerChannelAdapter('mock', { factory: () => null, defaults: decl });
await reg.initChannelAdapters(mockSetup);
expect(reg.getChannelDefaults('mock')).toBe(decl);
});
it('resolves a stale live instance through its channelType registration (registration tier instance→channelType)', async () => {
const reg = await import('./channel-registry.js');
const decl = makeDefaults('platform-registration');
// Stale adapter copy: live under a named-instance key with NO declaration;
// the platform's registration (keyed by channelType) carries one.
reg.registerChannelAdapter('slack-tester', {
factory: () => makeAdapter('slack', { instance: 'slack-tester' }),
});
reg.registerChannelAdapter('slack', { factory: () => null, defaults: decl });
await reg.initChannelAdapters(mockSetup);
expect(reg.getChannelDefaults('slack-tester')).toBe(decl);
});
it('resolves a dead named instance through the channelType hint (registration tier instance→channelType)', async () => {
const reg = await import('./channel-registry.js');
const decl = makeDefaults('platform-registration');
// Nothing live at all: the named instance's factory returned null and its
// registration has no declaration — only mg.channel_type can bridge.
reg.registerChannelAdapter('slack-tester', { factory: () => null });
reg.registerChannelAdapter('slack', { factory: () => null, defaults: decl });
await reg.initChannelAdapters(mockSetup);
expect(reg.getChannelDefaults('slack-tester', 'slack')).toBe(decl);
// Without the hint there is no instance→channelType mapping in the registry.
expect(reg.getChannelDefaults('slack-tester')).toEqual(reg.fallbackChannelDefaults(false));
});
it('uses the live adapter supportsThreads for the fallback tier', async () => {
const reg = await import('./channel-registry.js');
reg.registerChannelAdapter('mock', {
factory: () => makeAdapter('mock', { supportsThreads: true }),
});
await reg.initChannelAdapters(mockSetup);
expect(reg.getChannelDefaults('mock')).toEqual(reg.fallbackChannelDefaults(true));
});
it('unknown channel type resolves the conservative fallback', async () => {
const reg = await import('./channel-registry.js');
expect(reg.getChannelDefaults('no-such-channel')).toEqual(reg.fallbackChannelDefaults(false));
});
});
describe('fallbackChannelDefaults — behavior-faithful values', () => {
beforeEach(() => {
vi.resetModules();
});
it('reproduces trunk behavior for undeclared adapters', async () => {
const { fallbackChannelDefaults } = await import('./channel-registry.js');
expect(fallbackChannelDefaults(true)).toEqual({
dm: { engageMode: 'pattern', engagePattern: '.', threads: true, unknownSenderPolicy: 'request_approval' },
group: { engageMode: 'mention-sticky', threads: true, unknownSenderPolicy: 'request_approval' },
mentions: 'platform',
});
// threads track the raw capability in BOTH contexts so NULL-inherit
// wirings behave exactly like today's supportsThreads-derived routing.
const nonThreaded = fallbackChannelDefaults(false);
expect(nonThreaded.dm.threads).toBe(false);
expect(nonThreaded.group.threads).toBe(false);
expect(nonThreaded.group.engageMode).toBe('mention-sticky');
});
});
describe('resolveWiringDefaults', () => {
beforeEach(() => {
vi.resetModules();
});
afterEach(async () => {
const { teardownChannelAdapters } = await import('./channel-registry.js');
await teardownChannelAdapters();
vi.resetModules();
});
async function withDeclaration(defaults: ChannelDefaults) {
const reg = await import('./channel-registry.js');
reg.registerChannelAdapter('mock', { factory: () => null, defaults });
return import('./channel-defaults.js');
}
it('substitutes {name} with the regex-escaped agent group name', async () => {
const { resolveWiringDefaults } = await withDeclaration({
dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'public' },
group: { engageMode: 'pattern', engagePattern: '\\b{name}\\b', threads: false, unknownSenderPolicy: 'strict' },
mentions: 'dm-only',
});
expect(resolveWiringDefaults('mock', true, 'C-3PO (dev)')).toEqual({
engage_mode: 'pattern',
engage_pattern: '\\bC-3PO \\(dev\\)\\b',
});
// DM context: no token, pattern passes through untouched.
expect(resolveWiringDefaults('mock', false, 'C-3PO (dev)')).toEqual({
engage_mode: 'pattern',
engage_pattern: '.',
});
});
it('coerces mention-sticky to mention when the context threads=false', async () => {
const { resolveWiringDefaults } = await withDeclaration({
dm: { engageMode: 'mention', threads: false, unknownSenderPolicy: 'strict' },
group: { engageMode: 'mention-sticky', threads: false, unknownSenderPolicy: 'strict' },
mentions: 'platform',
});
expect(resolveWiringDefaults('mock', true, 'Andy')).toEqual({
engage_mode: 'mention',
engage_pattern: null,
});
});
it('keeps mention-sticky when the context threads=true', async () => {
const { resolveWiringDefaults } = await withDeclaration({
dm: { engageMode: 'mention', threads: true, unknownSenderPolicy: 'strict' },
group: { engageMode: 'mention-sticky', threads: true, unknownSenderPolicy: 'strict' },
mentions: 'platform',
});
expect(resolveWiringDefaults('mock', true, 'Andy')).toEqual({
engage_mode: 'mention-sticky',
engage_pattern: null,
});
});
it('throws on a pattern-mode declaration without a pattern', async () => {
const { resolveWiringDefaults } = await withDeclaration({
dm: { engageMode: 'pattern', threads: false, unknownSenderPolicy: 'public' },
group: { engageMode: 'mention', threads: false, unknownSenderPolicy: 'strict' },
mentions: 'platform',
});
expect(() => resolveWiringDefaults('mock', false, 'Andy')).toThrow(/without an engagePattern/);
});
});
describe('resolveUnknownSenderPolicy', () => {
beforeEach(() => {
vi.resetModules();
});
afterEach(() => {
vi.resetModules();
});
it('selects the context policy from the declaration', async () => {
const reg = await import('./channel-registry.js');
reg.registerChannelAdapter('mock', {
factory: () => null,
defaults: {
dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'public' },
group: { engageMode: 'mention', threads: false, unknownSenderPolicy: 'strict' },
mentions: 'platform',
},
});
const { resolveUnknownSenderPolicy } = await import('./channel-defaults.js');
expect(resolveUnknownSenderPolicy('mock', false)).toBe('public');
expect(resolveUnknownSenderPolicy('mock', true)).toBe('strict');
});
});
describe('resolveThreadPolicy', () => {
it('ANDs the resolved value with the raw capability', async () => {
vi.resetModules();
const { resolveThreadPolicy } = await import('./channel-defaults.js');
const decl: ChannelDefaults = {
dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'strict' },
group: { engageMode: 'mention-sticky', threads: true, unknownSenderPolicy: 'strict' },
mentions: 'platform',
};
// NULL = inherit the declaration for the context.
expect(resolveThreadPolicy(null, decl, true, true)).toBe(true);
expect(resolveThreadPolicy(null, decl, false, true)).toBe(false);
// Explicit wiring value beats the declaration…
expect(resolveThreadPolicy(1, decl, false, true)).toBe(true);
expect(resolveThreadPolicy(0, decl, true, true)).toBe(false);
// …but never the capability: no opt-in on a non-threaded platform.
expect(resolveThreadPolicy(1, decl, true, false)).toBe(false);
expect(resolveThreadPolicy(null, decl, true, false)).toBe(false);
});
});
-86
View File
@@ -1,86 +0,0 @@
/**
* Wiring-creation helpers over channel default declarations.
*
* Every path that creates a messaging_group_agents row (ncl, setup wizard,
* card-approval flow, bootstrap scripts) resolves its engage defaults through
* resolveWiringDefaults; every path that auto-creates a messaging_groups row
* resolves its policy through resolveUnknownSenderPolicy. The router's fanout
* consults resolveThreadPolicy at runtime threading is the one per-wiring
* setting that stays live (NULL = inherit the declaration) rather than being
* snapshotted at creation.
*
* Context selection everywhere: isGroup = event.message.isGroup ??
* (mg.is_group === 1) NEVER `threadId !== null` (DM sub-threads exist on
* Slack/Discord, and non-threaded group platforms have null threadIds).
*/
import type { ChannelDefaults } from './adapter.js';
import { getChannelDefaults } from './channel-registry.js';
function escapeRegex(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Resolve the engage defaults a new wiring should be created with.
*
* @param channelKey mg.instance ?? mg.channel_type (getChannelAdapter key discipline)
* @param isGroup event.message.isGroup ?? mg.is_group === 1 never derived from threadId
* @param agentGroupName substituted (regex-escaped) for the `{name}` token in declared patterns
*
* mention-sticky is downgraded to mention when the context's declared threads
* value is false: sticky engagement is keyed on per-thread session existence,
* so without thread ids it could engage once and never disengage.
*/
export function resolveWiringDefaults(
channelKey: string,
isGroup: boolean,
agentGroupName: string,
): { engage_mode: 'pattern' | 'mention' | 'mention-sticky'; engage_pattern: string | null } {
const decl = getChannelDefaults(channelKey);
const ctx = isGroup ? decl.group : decl.dm;
let mode = ctx.engageMode;
if (mode === 'mention-sticky' && !ctx.threads) mode = 'mention';
if (mode !== 'pattern') return { engage_mode: mode, engage_pattern: null };
if (!ctx.engagePattern) {
throw new Error(
`Channel '${channelKey}' declares engageMode 'pattern' without an engagePattern (${isGroup ? 'group' : 'dm'} context)`,
);
}
return {
engage_mode: 'pattern',
engage_pattern: ctx.engagePattern.replaceAll('{name}', escapeRegex(agentGroupName)),
};
}
/** unknown_sender_policy for a messaging_groups row created in this context. */
export function resolveUnknownSenderPolicy(
channelKey: string,
isGroup: boolean,
): 'strict' | 'request_approval' | 'public' {
const decl = getChannelDefaults(channelKey);
return (isGroup ? decl.group : decl.dm).unknownSenderPolicy;
}
/**
* Runtime thread policy for one wiring: does its event-derived address keep
* thread ids? wiring.threads (0/1, NULL = inherit the declaration) hard-ANDed
* with the adapter's raw capability a wiring can opt out of threads on a
* threaded platform, never opt in on a non-threaded one.
*
* Applies ONLY to event-derived addresses. `event.replyTo` is operator intent
* from the CLI admin transport (src/channels/adapter.ts) and must never be
* nulled through this policy.
*/
export function resolveThreadPolicy(
wiringThreads: number | null,
decl: ChannelDefaults,
isGroup: boolean,
supportsThreads: boolean,
): boolean {
const inherited = (isGroup ? decl.group : decl.dm).threads;
const wanted = wiringThreads === null ? inherited : wiringThreads !== 0;
return wanted && supportsThreads;
}
+3 -81
View File
@@ -4,7 +4,7 @@
* Channels self-register on import. The host calls initChannelAdapters() at startup
* to instantiate and set up all registered adapters.
*/
import type { ChannelAdapter, ChannelDefaults, ChannelRegistration, ChannelSetup } from './adapter.js';
import type { ChannelAdapter, ChannelRegistration, ChannelSetup } from './adapter.js';
import { log } from '../log.js';
const SETUP_RETRY_DELAYS_MS = [2000, 5000, 10000];
@@ -31,76 +31,6 @@ export function getChannelAdapter(channelType: string): ChannelAdapter | undefin
return activeAdapters.get(channelType);
}
/**
* Behavior-faithful fallback for adapters with no `defaults` declaration
* (stale skill-installed copies, unknown channel types). Values reproduce
* what trunk did before declarations existed, so a trunk update alone
* changes nothing for undeclared adapters:
* - dm: pattern '.' (every DM message engages), router auto-create policy
* 'request_approval' (src/router.ts auto-create branch).
* - group: mention-sticky (what the card-approval flow stamped on group
* channels), same 'request_approval' policy.
* - threads follow the raw capability in BOTH contexts a NULL (inherit)
* wiring resolved through this fallback behaves exactly like today's
* supportsThreads-derived routing.
* - mentions 'platform': never blocks a mention wiring at creation time.
*/
export function fallbackChannelDefaults(supportsThreads: boolean): ChannelDefaults {
return {
dm: {
engageMode: 'pattern',
engagePattern: '.',
threads: supportsThreads,
unknownSenderPolicy: 'request_approval',
},
group: {
engageMode: 'mention-sticky',
threads: supportsThreads,
unknownSenderPolicy: 'request_approval',
},
mentions: 'platform',
};
}
/**
* Resolve a channel's declared wiring defaults. Never returns undefined.
*
* `key` follows the same discipline as getChannelAdapter: mg.instance ??
* mg.channel_type. Tiers, first hit wins:
* 1. live adapter, instance-exact lets an instance carry env-computed
* declarations (e.g. WhatsApp shared-number mode);
* 2. live adapter of that channelType (mirrors getChannelAdapter's scan);
* 3. registration entry under the key covers offline scripts and
* factories that returned null for missing creds;
* 4. registration entry under the channelType resolved from the live
* adapter found in tiers 1-2 (a stale adapter copy without a declaration
* whose registration has one), else from the optional `channelType`
* hint, which callers holding a named-instance mg row should pass so a
* dead instance still resolves its platform's declaration;
* 5. fallbackChannelDefaults on the live adapter's capability (false when
* no adapter is live conservative, reachable only from manual creation
* surfaces since the router never sees events for unregistered channels).
*/
export function getChannelDefaults(key: string, channelType?: string): ChannelDefaults {
let live = activeAdapters.get(key);
if (!live) {
for (const adapter of activeAdapters.values()) {
if (adapter.channelType === key) {
live = adapter;
break;
}
}
}
if (live?.defaults) return live.defaults;
const typeKey = live?.channelType ?? channelType;
const registered =
registry.get(key)?.defaults ?? (typeKey !== undefined ? registry.get(typeKey)?.defaults : undefined);
if (registered) return registered;
return fallbackChannelDefaults(live?.supportsThreads ?? false);
}
/** Get all active adapters. */
export function getActiveAdapters(): ChannelAdapter[] {
return [...activeAdapters.values()];
@@ -155,16 +85,8 @@ export async function initChannelAdapters(setupFn: (adapter: ChannelAdapter) =>
throw err;
}
}
// Adapters key by instance (default instance = channelType), so N
// instances of one platform coexist. Duplicate keys warn instead of
// throwing — boot stays resilient, matching the historical silent
// last-write-wins, but now visibly.
const key = adapter.instance ?? adapter.channelType;
if (activeAdapters.has(key)) {
log.warn('Duplicate adapter instance key — overwriting previous adapter', { key, channel: name });
}
activeAdapters.set(key, adapter);
log.info('Channel adapter started', { channel: name, type: adapter.channelType, instance: key });
activeAdapters.set(adapter.channelType, adapter);
log.info('Channel adapter started', { channel: name, type: adapter.channelType });
} catch (err) {
log.error('Failed to start channel adapter', { channel: name, err });
}
+4 -13
View File
@@ -21,7 +21,7 @@ import { SqliteStateAdapter } from '../state-sqlite.js';
import { registerWebhookAdapter } from '../webhook-server.js';
import { getAskQuestionRender } from '../db/sessions.js';
import { normalizeOptions, type NormalizedOption } from './ask-question.js';
import type { ChannelAdapter, ChannelDefaults, ChannelSetup, InboundMessage } from './adapter.js';
import type { ChannelAdapter, ChannelSetup, InboundMessage } from './adapter.js';
/** Adapter with optional gateway support (e.g., Discord). */
interface GatewayAdapter extends Adapter {
@@ -57,12 +57,6 @@ export interface ChatSdkBridgeConfig {
* way and the default depends on installation style.
*/
supportsThreads: boolean;
/**
* Declared wiring-time defaults for this channel. Copied verbatim onto the
* returned ChannelAdapter, exactly like supportsThreads. See
* `ChannelAdapter.defaults`.
*/
defaults?: ChannelDefaults;
/**
* Optional transform applied to outbound text/markdown before it reaches the
* adapter. Used by channels that need to sanitize for a platform-specific
@@ -200,7 +194,6 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
name: adapter.name,
channelType: adapter.name,
supportsThreads: config.supportsThreads,
defaults: config.defaults,
async setup(hostConfig: ChannelSetup) {
setupConfig = hostConfig;
@@ -241,11 +234,9 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
});
// DMs — by definition addressed to the bot. Thread id flows through
// unmodified (Slack users can open sub-threads inside a DM); whether it
// is honored is policy, not transport: the channel's declared
// dm.threads default (ChannelDefaults) or a per-wiring threads override
// decides at router fanout whether replies land in-thread or all DM
// sub-threads collapse into the one DM session.
// so sub-thread context reaches delivery (Slack users can open threads
// inside a DM). Router collapses DM sub-threads to one session via
// is_group=0 short-circuit.
chat.onDirectMessage(async (thread, message) => {
const channelId = adapter.channelIdFromThreadId(thread.id);
log.info('Inbound DM received', {
+2 -22
View File
@@ -39,30 +39,11 @@ import path from 'path';
import { DATA_DIR } from '../config.js';
import { log } from '../log.js';
import type {
ChannelAdapter,
ChannelDefaults,
ChannelSetup,
DeliveryAddress,
InboundEvent,
OutboundMessage,
} from './adapter.js';
import type { ChannelAdapter, ChannelSetup, DeliveryAddress, InboundEvent, OutboundMessage } from './adapter.js';
import { registerChannelAdapter } from './channel-registry.js';
const PLATFORM_ID = 'local';
/**
* Terminal transport: every line the operator types is for the agent
* (pattern '.'), the socket is owner-only so senders are trusted ('public'),
* there is no thread or mention concept. Matches what
* scripts/init-cli-agent.ts has always created.
*/
const CLI_DEFAULTS: ChannelDefaults = {
dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'public' },
group: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'public' },
mentions: 'never',
};
function socketPath(): string {
return path.join(DATA_DIR, 'cli.sock');
}
@@ -75,7 +56,6 @@ function createAdapter(): ChannelAdapter {
name: 'cli',
channelType: 'cli',
supportsThreads: false,
defaults: CLI_DEFAULTS,
async setup(config: ChannelSetup): Promise<void> {
const sock = socketPath();
@@ -293,4 +273,4 @@ function extractText(message: OutboundMessage): string | null {
return null;
}
registerChannelAdapter('cli', { factory: createAdapter, defaults: CLI_DEFAULTS });
registerChannelAdapter('cli', { factory: createAdapter });
@@ -1,28 +0,0 @@
/**
* Integration test for the deltachat channel's single reach-in: the
* self-registration import in the `src/channels/index.ts` barrel. Importing the
* barrel runs deltachat.ts's top-level `registerChannelAdapter('deltachat', )`;
* without the import the channel is silently absent.
*
* Behavior, not structural: it imports the real barrel and asserts the registry
* actually contains the channel. This reflects what happens at host boot if the
* `import './deltachat.js';` line is deleted, or the barrel fails to evaluate for
* any reason (so the channel genuinely would not register), this goes red. A
* structural check of the import line would falsely pass in that second case.
*
* Importing the barrel is safe: registration is a pure top-level call, and
* deltachat.ts only instantiates DeltaChatOverJsonRpc inside setup() (run at host
* startup), never at import so nothing spawns here. It does require the adapter
* package to be installed, which holds in a composed install: the skill's
* `pnpm install` step runs before this test in the apply flow.
*/
import { describe, it, expect } from 'vitest';
import { getRegisteredChannelNames } from './channel-registry.js';
import './index.js'; // the real barrel — triggers every channel's self-registration
describe('deltachat channel registration', () => {
it('registers deltachat via the channel barrel', () => {
expect(getRegisteredChannelNames()).toContain('deltachat');
});
});
-356
View File
@@ -1,356 +0,0 @@
/**
* DeltaChat channel adapter.
*
* Bridges NanoClaw with DeltaChat via the @deltachat/stdio-rpc-server JSON-RPC
* process. Each DeltaChat chat becomes a separate NanoClaw messaging group
* (platformId = chatId string, e.g. "12"). No thread model supportsThreads: false.
*
* Required env vars (.env): DC_EMAIL, DC_PASSWORD,
* DC_IMAP_HOST, DC_IMAP_PORT,
* DC_SMTP_HOST, DC_SMTP_PORT
* Optional env vars (.env): DC_IMAP_SECURITY (default: "1" = SSL/TLS),
* DC_SMTP_SECURITY (default: "2" = STARTTLS)
* Security values: 1=SSL/TLS, 2=STARTTLS, 3=plain
* Optional env vars (service unit): DC_ACCOUNT_DIR (default: "dc-account"),
* DC_DISPLAY_NAME, DC_AVATAR_PATH
*/
import { existsSync, mkdtempSync, writeFileSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { basename, join, resolve } from 'path';
import { getDb, hasTable } from '../db/connection.js';
import { readEnvFile } from '../env.js';
import { log } from '../log.js';
import type { ChannelAdapter, ChannelDefaults, ChannelSetup, OutboundMessage } from './adapter.js';
import { registerChannelAdapter } from './channel-registry.js';
import { DeltaChatOverJsonRpc } from '@deltachat/stdio-rpc-server';
const REQUIRED_ENV = [
'DC_EMAIL',
'DC_PASSWORD',
'DC_IMAP_HOST',
'DC_IMAP_PORT',
'DC_SMTP_HOST',
'DC_SMTP_PORT',
] as const;
const OPTIONAL_ENV = ['DC_IMAP_SECURITY', 'DC_SMTP_SECURITY'] as const;
type DcEnv = { [K in (typeof REQUIRED_ENV)[number]]: string } & { [K in (typeof OPTIONAL_ENV)[number]]?: string };
function isDcAdmin(userId: string): boolean {
try {
const db = getDb();
if (!hasTable(db, 'user_roles')) return true;
return (
db
.prepare(
`SELECT 1 FROM user_roles
WHERE user_id = ?
AND (role = 'owner' OR role = 'admin')
AND agent_group_id IS NULL
LIMIT 1`,
)
.get(userId) != null
);
} catch {
return false;
}
}
function createAdapter(env: DcEnv): ChannelAdapter {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let dc: any = null;
let accountId = 0;
let connectivity = 0;
let lastImapIdleTs = Date.now();
let consecutiveBadChecks = 0;
let watchdogTimer: ReturnType<typeof setInterval> | null = null;
let networkTimer: ReturnType<typeof setInterval> | null = null;
async function restartIo(reason: string): Promise<void> {
log.warn('DeltaChat: restarting IO', { reason });
try {
await dc.rpc.stopIo(accountId);
await dc.rpc.startIo(accountId);
lastImapIdleTs = Date.now();
consecutiveBadChecks = 0;
} catch (err) {
log.error('DeltaChat: IO restart failed', { err });
}
}
const adapter: ChannelAdapter = {
name: 'deltachat',
channelType: 'deltachat',
supportsThreads: false,
defaults: DELTACHAT_DEFAULTS,
async setup(config: ChannelSetup): Promise<void> {
const accountDir = process.env.DC_ACCOUNT_DIR ?? 'dc-account';
dc = new DeltaChatOverJsonRpc(accountDir, {});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
dc.on('Error', (_: any, event: any) => log.error('DeltaChat RPC error', { msg: event.msg ?? event }));
const accounts = await dc.rpc.getAllAccounts();
accountId = accounts[0]?.id;
if (!accountId) accountId = await dc.rpc.addAccount();
const imapSecurity = env.DC_IMAP_SECURITY ?? '1';
const smtpSecurity = env.DC_SMTP_SECURITY ?? '2';
if (!(await dc.rpc.isConfigured(accountId))) {
await dc.rpc.setConfig(accountId, 'addr', env.DC_EMAIL);
await dc.rpc.setConfig(accountId, 'mail_pw', env.DC_PASSWORD);
await dc.rpc.setConfig(accountId, 'mail_server', env.DC_IMAP_HOST);
await dc.rpc.setConfig(accountId, 'mail_port', env.DC_IMAP_PORT);
await dc.rpc.setConfig(accountId, 'send_server', env.DC_SMTP_HOST);
await dc.rpc.setConfig(accountId, 'send_port', env.DC_SMTP_PORT);
await dc.rpc.configure(accountId);
log.info('DeltaChat: account configured', { email: env.DC_EMAIL });
} else {
log.info('DeltaChat: account ready', { email: env.DC_EMAIL });
}
await dc.rpc.setConfig(accountId, 'mail_security', imapSecurity);
await dc.rpc.setConfig(accountId, 'send_security', smtpSecurity);
await dc.rpc.setConfig(accountId, 'displayname', process.env.DC_DISPLAY_NAME ?? 'NanoClaw');
const avatarPath = process.env.DC_AVATAR_PATH;
if (avatarPath && existsSync(avatarPath)) {
await dc.rpc.setConfig(accountId, 'selfavatar', avatarPath);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
dc.on('IncomingMsg', async (contextId: number, event: any) => {
if (contextId !== accountId) return;
try {
let msg = await dc.rpc.getMessage(accountId, event.msgId);
if (msg.isInfo) return;
// Wait for large-message download to complete
if (msg.downloadState !== 'Done') {
await dc.rpc.downloadFullMessage(accountId, event.msgId);
for (let i = 0; i < 30; i++) {
await new Promise((r) => setTimeout(r, 1000));
msg = await dc.rpc.getMessage(accountId, event.msgId);
if (msg.downloadState === 'Done') break;
}
}
if (!msg.text && !msg.file) return;
const contact = await dc.rpc.getContact(accountId, msg.fromId);
const chat = await dc.rpc.getBasicChatInfo(accountId, event.chatId);
if (/^\/set-avatar$/i.test((msg.text || '').trim()) && msg.file) {
const userId = `deltachat:${contact.address}`;
try {
if (isDcAdmin(userId)) {
const absPath = resolve(msg.file as string);
await dc.rpc.setConfig(accountId, 'selfavatar', absPath);
await dc.rpc.sendMsg(accountId, event.chatId, { text: 'Avatar updated.' });
} else {
await dc.rpc.sendMsg(accountId, event.chatId, { text: 'Permission denied.' });
}
} catch (avatarErr: unknown) {
log.error('DeltaChat: failed to set avatar', {
err: avatarErr instanceof Error ? avatarErr.message : JSON.stringify(avatarErr),
});
await dc.rpc.sendMsg(accountId, event.chatId, { text: 'Failed to set avatar.' }).catch(() => {});
}
return;
}
const content: Record<string, unknown> = {
text: msg.text || '',
sender: contact.displayName || contact.address,
senderId: contact.address,
};
if (msg.file) {
content.attachments = [
{
name: basename(msg.file as string),
type: 'file',
localPath: msg.file,
},
];
}
const isGroup = chat?.isGroup ?? false;
await config.onInbound(String(event.chatId), null, {
id: String(event.msgId),
kind: 'chat',
content,
timestamp: new Date().toISOString(),
isGroup,
isMention: !isGroup,
});
} catch (err: unknown) {
log.error('DeltaChat: error handling incoming message', {
err: err instanceof Error ? err.message : JSON.stringify(err),
});
}
});
dc.on('ImapInboxIdle', (contextId: number) => {
if (contextId === accountId) lastImapIdleTs = Date.now();
});
dc.on('ConnectivityChanged', async (contextId: number) => {
if (contextId !== accountId) return;
try {
connectivity = await dc.rpc.getConnectivity(accountId);
} catch {
/* ignore */
}
});
await dc.rpc.startIo(accountId);
try {
connectivity = await dc.rpc.getConnectivity(accountId);
} catch {
/* ignore */
}
log.info('DeltaChat: IO started', { email: env.DC_EMAIL });
// Log invite link on every startup so the operator can bootstrap the first contact.
// In DeltaChat, contacts can't simply be added by email — the user must open this
// https://i.delta.chat/ invite URL in their DeltaChat app (or scan invite-qr.svg) to initiate contact.
try {
// null chatId → Setup-Contact invite (not group-specific)
const [inviteUrl, svg] = await dc.rpc.getChatSecurejoinQrCodeSvg(accountId, null);
const accountDir = resolve(process.env.DC_ACCOUNT_DIR ?? 'dc-account');
const svgPath = join(accountDir, 'invite-qr.svg');
writeFileSync(svgPath, svg);
log.info('DeltaChat: invite link — open URL in DeltaChat app or scan ' + svgPath, { url: inviteUrl });
} catch (err: unknown) {
log.warn('DeltaChat: could not generate invite link', {
err: err instanceof Error ? err.message : JSON.stringify(err),
});
}
// Connectivity watchdog: restart IO if IMAP goes quiet or connectivity drops
watchdogTimer = setInterval(
async () => {
try {
const conn = await dc.rpc.getConnectivity(accountId);
connectivity = conn;
if (conn < 3000) {
consecutiveBadChecks++;
if (consecutiveBadChecks >= 2) {
await restartIo(`connectivity=${conn} for 2 consecutive checks`);
}
} else {
consecutiveBadChecks = 0;
}
const idleAgeMin = (Date.now() - lastImapIdleTs) / 60000;
if (idleAgeMin > 20) {
await restartIo(`no IMAP IDLE in ${idleAgeMin.toFixed(0)}min`);
}
} catch (err: unknown) {
log.warn('DeltaChat: watchdog error', {
err: err instanceof Error ? err.message : String(err),
});
}
},
5 * 60 * 1000,
);
// Nudge the network stack every 10 minutes (recovers from prolonged idle)
networkTimer = setInterval(
async () => {
try {
await dc.rpc.maybeNetwork();
} catch {
/* ignore */
}
},
10 * 60 * 1000,
);
},
async teardown(): Promise<void> {
if (watchdogTimer) clearInterval(watchdogTimer);
if (networkTimer) clearInterval(networkTimer);
try {
await dc?.rpc.stopIo(accountId);
} catch {
/* ignore */
}
try {
dc?.close();
} catch {
/* ignore */
}
},
isConnected(): boolean {
// 4000 = fully connected (IMAP), 3000 = connecting; treat ≥3000 as live
return connectivity >= 3000;
},
async deliver(platformId: string, _threadId: string | null, message: OutboundMessage): Promise<string | undefined> {
const chatId = parseInt(platformId, 10);
if (isNaN(chatId)) {
log.warn('DeltaChat: invalid platformId for delivery', { platformId });
return undefined;
}
const content = message.content as Record<string, unknown>;
const text = typeof content.text === 'string' ? content.text : '';
if (message.files && message.files.length > 0) {
const tempDir = mkdtempSync(join(tmpdir(), 'nanoclaw-dc-'));
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let firstId: any;
for (let i = 0; i < message.files.length; i++) {
const f = message.files[i];
const tempPath = join(tempDir, f.filename);
writeFileSync(tempPath, f.data);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const params: any = { file: tempPath };
if (i === 0 && text) params.text = text;
const sentId = await dc.rpc.sendMsg(accountId, chatId, params);
if (i === 0) firstId = sentId;
}
return firstId != null ? String(firstId) : undefined;
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
}
if (!text) return undefined;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sentId: any = await dc.rpc.sendMsg(accountId, chatId, { text });
return sentId != null ? String(sentId) : undefined;
},
};
return adapter;
}
/**
* Dedicated email identity (DC_EMAIL), so request_approval is sound. Email
* carries no mention metadata ('dm-only'; the adapter flags DMs only), so
* group wirings default to a name-pattern trigger.
*/
const DELTACHAT_DEFAULTS: ChannelDefaults = {
dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'request_approval' },
group: {
engageMode: 'pattern',
engagePattern: '\\b{name}\\b',
threads: false,
unknownSenderPolicy: 'request_approval',
},
mentions: 'dm-only',
};
registerChannelAdapter('deltachat', {
factory: () => {
const env = readEnvFile([...REQUIRED_ENV, ...OPTIONAL_ENV]);
if (!env.DC_EMAIL || !env.DC_PASSWORD) return null;
return createAdapter(env as DcEnv);
},
defaults: DELTACHAT_DEFAULTS,
});
-34
View File
@@ -1,34 +0,0 @@
/**
* Integration test for the discord channel's single reach-in: the self-registration
* import in the `src/channels/index.ts` barrel. Importing the barrel runs discord.ts's
* top-level `registerChannelAdapter('discord', …)`; without the import the channel is
* silently absent.
*
* Behavior, not structural: it imports the real barrel and asserts the registry
* actually contains the channel. This reflects what happens at host boot if the
* `import './discord.js';` line is deleted, or the barrel fails to evaluate for any
* reason (so the channel genuinely would not register), this goes red. A structural
* check of the import line would falsely pass in that second case.
*
* Importing the barrel is safe: registration is a pure top-level call, and discord.ts
* builds the SDK adapter / bridge only inside its factory (invoked at host startup),
* never at import. It does require the adapter package (`@chat-adapter/discord`) to be installed,
* which holds in a composed install: the skill's `pnpm install` step runs before this
* test so this test also implicitly guards that dependency (an unmocked import throws
* if the package is missing).
*
* discord is a Chat SDK channel: discord.ts also consumes a load-bearing *core* API
* `createChatSdkBridge(...)` from ./chat-sdk-bridge.js. That core-consumption is a
* typed call, so the build/typecheck leg (`pnpm run build`) guards it against upstream
* drift, not this test. Every Chat SDK channel follows this same shape.
*/
import { describe, it, expect } from 'vitest';
import { getRegisteredChannelNames } from './channel-registry.js';
import './index.js'; // the real barrel — triggers every channel's self-registration
describe('discord channel registration', () => {
it('registers discord via the channel barrel', () => {
expect(getRegisteredChannelNames()).toContain('discord');
});
});
-76
View File
@@ -1,76 +0,0 @@
import { describe, expect, it } from 'vitest';
import { unwrapForwardedSnapshot } from './discord.js';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function forwardPayload(snapshotMessage: Record<string, any> | null, overrides: Record<string, any> = {}) {
return {
id: '123',
content: '',
attachments: [],
message_reference: { type: 1, channel_id: 'c1', message_id: 'm1' },
...(snapshotMessage ? { message_snapshots: [{ message: snapshotMessage }] } : {}),
...overrides,
};
}
describe('unwrapForwardedSnapshot', () => {
it('unwraps forwarded text into content with a label', () => {
const data = forwardPayload({ content: 'hello from the past', attachments: [] });
unwrapForwardedSnapshot(data);
expect(data.content).toBe('[Forwarded message]\nhello from the past');
});
it('unwraps attachment-only forwards: label + merged attachments', () => {
const att = { filename: 'photo.png', content_type: 'image/png', size: 1234, url: 'https://cdn.example/photo.png' };
const data = forwardPayload({ content: '', attachments: [att] });
unwrapForwardedSnapshot(data);
expect(data.content).toBe('[Forwarded message]');
expect(data.attachments).toEqual([att]);
});
it('merges snapshot attachments after existing ones', () => {
const existing = { filename: 'own.txt', content_type: 'text/plain', size: 1, url: 'https://cdn.example/own.txt' };
const fwd = { filename: 'fwd.jpg', content_type: 'image/jpeg', size: 2, url: 'https://cdn.example/fwd.jpg' };
const data = forwardPayload({ content: 'look', attachments: [fwd] }, { attachments: [existing] });
unwrapForwardedSnapshot(data);
expect(data.attachments).toEqual([existing, fwd]);
expect(data.content).toBe('[Forwarded message]\nlook');
});
it('leaves plain messages untouched', () => {
const data = { id: '1', content: 'hi', attachments: [] };
unwrapForwardedSnapshot(data);
expect(data).toEqual({ id: '1', content: 'hi', attachments: [] });
});
it('leaves normal replies (type 0) untouched', () => {
const data = {
id: '1',
content: 'a reply',
attachments: [],
message_reference: { type: 0, message_id: 'm0' },
referenced_message: { content: 'original', author: { username: 'alice' } },
};
unwrapForwardedSnapshot(data);
expect(data.content).toBe('a reply');
});
it('is a no-op when a forward has no snapshots', () => {
const data = forwardPayload(null);
unwrapForwardedSnapshot(data);
expect(data.content).toBe('');
expect(data.attachments).toEqual([]);
});
it('joins multiple snapshots', () => {
const data = forwardPayload(null, {
message_snapshots: [
{ message: { content: 'one', attachments: [] } },
{ message: { content: 'two', attachments: [] } },
],
});
unwrapForwardedSnapshot(data);
expect(data.content).toBe('[Forwarded message]\none\ntwo');
});
});
-98
View File
@@ -1,98 +0,0 @@
/**
* Discord channel adapter (v2) uses Chat SDK bridge.
* Self-registers on import.
*/
import { createDiscordAdapter } from '@chat-adapter/discord';
import { readEnvFile } from '../env.js';
import type { ChannelDefaults } from './adapter.js';
import { createChatSdkBridge, type ReplyContext } from './chat-sdk-bridge.js';
import { registerChannelAdapter } from './channel-registry.js';
/**
* Dedicated bot app on a threaded platform. group threads:true matches the
* declared supportsThreads (the skill-installed install-style knob) so
* mention-sticky engagement stays bounded per-thread. dm.threads:false
* DM replies land top-level, one session per DM.
*/
const DISCORD_DEFAULTS: ChannelDefaults = {
dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'request_approval' },
group: { engageMode: 'mention-sticky', threads: true, unknownSenderPolicy: 'request_approval' },
mentions: 'platform',
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function extractReplyContext(raw: Record<string, any>): ReplyContext | null {
if (!raw.referenced_message) return null;
const reply = raw.referenced_message;
return {
text: reply.content || '',
sender: reply.author?.global_name || reply.author?.username || 'Unknown',
};
}
/**
* Discord message forwards carry their content in `message_snapshots`, not
* `content` (`message_reference.type === 1` means FORWARD; 0 is a normal
* reply). The adapter only reads `content`/`attachments`, so without this the
* agent sees an empty message. Unwrap the snapshot back into the payload so
* text, attachment download, and formatting all ride the existing path.
* Note: snapshots contain no author, so the original sender is unavailable.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function unwrapForwardedSnapshot(data: Record<string, any>): void {
if (data.message_reference?.type !== 1) return;
const snaps = (data.message_snapshots ?? [])
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.map((s: any) => s?.message)
.filter(Boolean);
if (snaps.length === 0) return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const text = snaps
.map((m: any) => m.content)
.filter(Boolean)
.join('\n');
const label = '[Forwarded message]';
data.content = text ? `${label}\n${text}` : data.content || label;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const fwdAttachments = snaps.flatMap((m: any) => m.attachments ?? []);
if (fwdAttachments.length > 0) {
data.attachments = [...(data.attachments ?? []), ...fwdAttachments];
}
}
function unwrapForwards(adapter: ReturnType<typeof createDiscordAdapter>): void {
const a = adapter as unknown as {
handleForwardedMessage: (data: Record<string, unknown>, options?: unknown) => Promise<void>;
};
const orig = a.handleForwardedMessage.bind(adapter);
a.handleForwardedMessage = async (data, options) => {
unwrapForwardedSnapshot(data);
return orig(data, options);
};
}
registerChannelAdapter('discord', {
factory: () => {
const env = readEnvFile(['DISCORD_BOT_TOKEN', 'DISCORD_PUBLIC_KEY', 'DISCORD_APPLICATION_ID']);
if (!env.DISCORD_BOT_TOKEN) return null;
const discordAdapter = createDiscordAdapter({
botToken: env.DISCORD_BOT_TOKEN,
publicKey: env.DISCORD_PUBLIC_KEY,
applicationId: env.DISCORD_APPLICATION_ID,
});
unwrapForwards(discordAdapter);
return createChatSdkBridge({
adapter: discordAdapter,
concurrency: 'concurrent',
botToken: env.DISCORD_BOT_TOKEN,
extractReplyContext,
supportsThreads: true,
defaults: DISCORD_DEFAULTS,
// Discord rejects messages over 2000 chars; without this the bridge
// would let long agent replies fail instead of splitting them.
maxTextLength: 2000,
});
},
defaults: DISCORD_DEFAULTS,
});
-29
View File
@@ -1,29 +0,0 @@
/**
* Integration test for the emacs channel's single reach-in: the self-registration
* import in the `src/channels/index.ts` barrel. Importing the barrel runs emacs.ts's
* top-level `registerChannelAdapter('emacs', …)`; without the import the channel is
* silently absent.
*
* Behavior, not structural: it imports the real barrel and asserts the registry
* actually contains the channel. This reflects what happens at host boot if the
* `import './emacs.js';` line is deleted, or the barrel fails to evaluate for any
* reason (so the channel genuinely would not register), this goes red. A structural
* check of the import line would falsely pass in that second case.
*
* emacs is a native adapter with no npm dependency (it uses the Node http builtin); it talks to an Emacs HTTP client.
* Importing the barrel is safe: registration is a pure top-level call and emacs.ts
* opens connections / spawns subprocesses only inside setup() (run at host startup),
* never at import. There is no adapter package to guard here this test guards the
* one barrel reach-in (red if `import './emacs.js';` is deleted or the barrel fails
* to evaluate).
*/
import { describe, it, expect } from 'vitest';
import { getRegisteredChannelNames } from './channel-registry.js';
import './index.js'; // the real barrel — triggers every channel's self-registration
describe('emacs channel registration', () => {
it('registers emacs via the channel barrel', () => {
expect(getRegisteredChannelNames()).toContain('emacs');
});
});
-259
View File
@@ -1,259 +0,0 @@
/**
* Tests for the v2 emacs channel adapter.
*
* Exercises the HTTP surface (POST /api/message, GET /api/messages) and
* the ChannelAdapter lifecycle (setup / teardown / isConnected / deliver).
*/
import http from 'http';
import type { AddressInfo } from 'net';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createEmacsAdapter } from './emacs.js';
import type { ChannelAdapter, ChannelSetup } from './adapter.js';
vi.mock('../log.js', () => ({
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
function makeSetup(overrides: Partial<ChannelSetup> = {}): ChannelSetup {
return {
onInbound: vi.fn(),
onInboundEvent: vi.fn(),
onMetadata: vi.fn(),
onAction: vi.fn(),
...overrides,
};
}
/** Ask the OS for a free port, then immediately release it. Small race window
* before the adapter grabs it, but sufficient for local test use. */
async function getFreePort(): Promise<number> {
return new Promise((resolve, reject) => {
const srv = http.createServer();
srv.once('error', reject);
srv.listen(0, '127.0.0.1', () => {
const port = (srv.address() as AddressInfo).port;
srv.close(() => resolve(port));
});
});
}
async function req(
port: number,
method: string,
path: string,
body?: string,
extraHeaders: Record<string, string> = {},
): Promise<{ status: number; data: unknown }> {
return new Promise((resolve, reject) => {
const headers: Record<string, string> = { 'Content-Type': 'application/json', ...extraHeaders };
const request = http.request({ host: '127.0.0.1', port, method, path, headers }, (res) => {
let raw = '';
res.on('data', (chunk: Buffer) => (raw += chunk.toString()));
res.on('end', () => {
try {
resolve({ status: res.statusCode!, data: JSON.parse(raw) });
} catch {
resolve({ status: res.statusCode!, data: raw });
}
});
});
request.on('error', reject);
if (body) request.write(body);
request.end();
});
}
describe('emacs adapter', () => {
let adapter: ChannelAdapter;
let port: number;
beforeEach(async () => {
port = await getFreePort();
adapter = createEmacsAdapter({ port, authToken: null, platformId: 'default' });
});
afterEach(async () => {
if (adapter.isConnected()) await adapter.teardown();
});
describe('lifecycle', () => {
it('isConnected is false before setup', () => {
expect(adapter.isConnected()).toBe(false);
});
it('isConnected is true after setup', async () => {
await adapter.setup(makeSetup());
expect(adapter.isConnected()).toBe(true);
});
it('isConnected is false after teardown', async () => {
await adapter.setup(makeSetup());
await adapter.teardown();
expect(adapter.isConnected()).toBe(false);
});
it('teardown is a no-op before setup', async () => {
await expect(adapter.teardown()).resolves.not.toThrow();
});
it('calls onMetadata after setup with channel name', async () => {
const onMetadata = vi.fn();
await adapter.setup(makeSetup({ onMetadata }));
expect(onMetadata).toHaveBeenCalledWith('default', 'Emacs', false);
});
});
describe('POST /api/message', () => {
let onInbound: ChannelSetup['onInbound'] & { mock: { calls: unknown[][] } };
beforeEach(async () => {
onInbound = vi.fn() as unknown as typeof onInbound;
await adapter.setup(makeSetup({ onInbound }));
});
it('fires onInbound with chat kind and sender metadata', async () => {
const { status, data } = await req(port, 'POST', '/api/message', JSON.stringify({ text: 'hello' }));
expect(status).toBe(200);
expect((data as { messageId: string }).messageId).toMatch(/^emacs-/);
expect(onInbound).toHaveBeenCalledOnce();
const [platformId, threadId, msg] = onInbound.mock.calls[0] as [string, string | null, { content: unknown }];
expect(platformId).toBe('default');
expect(threadId).toBeNull();
expect(msg).toMatchObject({
kind: 'chat',
content: { text: 'hello', sender: 'Emacs', senderId: 'emacs:default' },
});
});
it('returns 400 for empty text', async () => {
const { status } = await req(port, 'POST', '/api/message', JSON.stringify({ text: '' }));
expect(status).toBe(400);
expect(onInbound).not.toHaveBeenCalled();
});
it('returns 400 for whitespace-only text', async () => {
const { status } = await req(port, 'POST', '/api/message', JSON.stringify({ text: ' ' }));
expect(status).toBe(400);
});
it('returns 400 for invalid JSON', async () => {
const { status } = await req(port, 'POST', '/api/message', 'not-json');
expect(status).toBe(400);
});
it('returns 404 for unknown paths', async () => {
const { status } = await req(port, 'POST', '/api/unknown', JSON.stringify({ text: 'hi' }));
expect(status).toBe(404);
});
});
describe('GET /api/messages + deliver', () => {
beforeEach(async () => {
await adapter.setup(makeSetup());
});
it('returns empty buffer initially', async () => {
const { status, data } = await req(port, 'GET', '/api/messages?since=0');
expect(status).toBe(200);
expect(data).toEqual({ messages: [] });
});
it('deliver pushes text for the poll endpoint to return', async () => {
await adapter.deliver('default', null, { kind: 'chat', content: { text: 'reply' } });
const { data } = await req(port, 'GET', '/api/messages?since=0');
const messages = (data as { messages: { text: string; timestamp: number }[] }).messages;
expect(messages).toHaveLength(1);
expect(messages[0]?.text).toBe('reply');
expect(typeof messages[0]?.timestamp).toBe('number');
});
it('deliver accepts plain-string content', async () => {
await adapter.deliver('default', null, { kind: 'chat', content: 'raw text' });
const { data } = await req(port, 'GET', '/api/messages?since=0');
expect((data as { messages: { text: string }[] }).messages[0]?.text).toBe('raw text');
});
it('deliver skips empty text silently', async () => {
await adapter.deliver('default', null, { kind: 'chat', content: { text: '' } });
const { data } = await req(port, 'GET', '/api/messages?since=0');
expect((data as { messages: unknown[] }).messages).toHaveLength(0);
});
it('deliver rejects unknown platformId', async () => {
const result = await adapter.deliver('other', null, { kind: 'chat', content: { text: 'x' } });
expect(result).toBeUndefined();
const { data } = await req(port, 'GET', '/api/messages?since=0');
expect((data as { messages: unknown[] }).messages).toHaveLength(0);
});
it('filters out messages at or before the since cutoff', async () => {
await adapter.deliver('default', null, { kind: 'chat', content: { text: 'old' } });
const since = Date.now();
await new Promise((r) => setTimeout(r, 5));
await adapter.deliver('default', null, { kind: 'chat', content: { text: 'new' } });
const { data } = await req(port, 'GET', `/api/messages?since=${since}`);
const texts = (data as { messages: { text: string }[] }).messages.map((m) => m.text);
expect(texts).not.toContain('old');
expect(texts).toContain('new');
});
it('caps buffer at 200 messages, evicting the oldest', async () => {
for (let i = 0; i < 205; i++) {
await adapter.deliver('default', null, { kind: 'chat', content: { text: `m-${i}` } });
}
const { data } = await req(port, 'GET', '/api/messages?since=0');
const messages = (data as { messages: { text: string }[] }).messages;
expect(messages).toHaveLength(200);
expect(messages.map((m) => m.text)).not.toContain('m-0');
expect(messages.map((m) => m.text)).toContain('m-5');
expect(messages.map((m) => m.text)).toContain('m-204');
});
});
describe('auth', () => {
let authAdapter: ChannelAdapter;
let authPort: number;
beforeEach(async () => {
authPort = await getFreePort();
authAdapter = createEmacsAdapter({ port: authPort, authToken: 'secret', platformId: 'default' });
await authAdapter.setup(makeSetup());
});
afterEach(async () => {
if (authAdapter.isConnected()) await authAdapter.teardown();
});
it('rejects POST without Authorization header', async () => {
const { status } = await req(authPort, 'POST', '/api/message', JSON.stringify({ text: 'hi' }));
expect(status).toBe(401);
});
it('rejects POST with wrong token', async () => {
const { status } = await req(authPort, 'POST', '/api/message', JSON.stringify({ text: 'hi' }), {
Authorization: 'Bearer wrong',
});
expect(status).toBe(401);
});
it('accepts POST with correct Bearer token', async () => {
const { status } = await req(authPort, 'POST', '/api/message', JSON.stringify({ text: 'hi' }), {
Authorization: 'Bearer secret',
});
expect(status).toBe(200);
});
it('rejects GET without Authorization header', async () => {
const { status } = await req(authPort, 'GET', '/api/messages?since=0');
expect(status).toBe(401);
});
it('accepts GET with correct Bearer token', async () => {
const { status } = await req(authPort, 'GET', '/api/messages?since=0', undefined, {
Authorization: 'Bearer secret',
});
expect(status).toBe(200);
});
});
});
-199
View File
@@ -1,199 +0,0 @@
/**
* Emacs channel adapter (v2) native HTTP bridge.
*
* Stands up a localhost HTTP server that the nanoclaw.el client talks to:
* - POST /api/message user typed a message in Emacs; fire onInbound
* - GET /api/messages?since=<ms> Emacs polls for agent replies
*
* Single-user, single-chat: one adapter instance = one messaging group with
* `platform_id = "default"` (override with EMACS_PLATFORM_ID). No threads,
* no cold DM. Self-registers on import.
*/
import http from 'http';
import { readEnvFile } from '../env.js';
import { log } from '../log.js';
import { registerChannelAdapter } from './channel-registry.js';
import type { ChannelAdapter, ChannelDefaults, ChannelSetup, InboundMessage, OutboundMessage } from './adapter.js';
const OUTBOUND_BUFFER_MAX = 200;
/**
* Single-operator localhost transport, wired manually: every line is for the
* agent, senders are whoever can reach the local port ('strict' keeps
* auto-create off), no thread or mention concept.
*/
const EMACS_DEFAULTS: ChannelDefaults = {
dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'strict' },
group: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'strict' },
mentions: 'never',
};
interface BufferedMessage {
text: string;
timestamp: number;
}
interface EmacsAdapterOptions {
port: number;
authToken: string | null;
platformId: string;
}
function createEmacsAdapter(opts: EmacsAdapterOptions): ChannelAdapter {
let server: http.Server | null = null;
let setupConfig: ChannelSetup | null = null;
const outboundBuffer: BufferedMessage[] = [];
function checkAuth(req: http.IncomingMessage, res: http.ServerResponse): boolean {
if (!opts.authToken) return true;
if (req.headers['authorization'] === `Bearer ${opts.authToken}`) return true;
res
.writeHead(401, { 'Content-Type': 'application/json; charset=utf-8' })
.end(JSON.stringify({ error: 'Unauthorized' }));
return false;
}
function handlePost(req: http.IncomingMessage, res: http.ServerResponse): void {
let body = '';
req.on('data', (chunk) => (body += chunk));
req.on('end', () => {
let text: string;
try {
const parsed = JSON.parse(body) as { text?: string };
text = parsed.text ?? '';
} catch {
res
.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' })
.end(JSON.stringify({ error: 'Invalid JSON' }));
return;
}
if (!text.trim()) {
res
.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' })
.end(JSON.stringify({ error: 'text required' }));
return;
}
const timestamp = new Date().toISOString();
const id = `emacs-${Date.now()}`;
const inbound: InboundMessage = {
id,
kind: 'chat',
content: {
text,
sender: 'Emacs',
senderId: `emacs:${opts.platformId}`,
},
timestamp,
};
try {
setupConfig?.onInbound(opts.platformId, null, inbound);
} catch (err) {
log.error('Emacs onInbound failed', { err });
}
res
.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' })
.end(JSON.stringify({ messageId: id, timestamp: Date.now() }));
});
}
function handlePoll(url: URL, res: http.ServerResponse): void {
const since = parseInt(url.searchParams.get('since') ?? '0', 10);
const messages = outboundBuffer.filter((m) => m.timestamp > since);
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }).end(JSON.stringify({ messages }));
}
return {
name: 'emacs',
channelType: 'emacs',
supportsThreads: false,
defaults: EMACS_DEFAULTS,
async setup(config: ChannelSetup): Promise<void> {
setupConfig = config;
server = http.createServer((req, res) => {
if (!checkAuth(req, res)) return;
const url = new URL(req.url ?? '/', `http://localhost:${opts.port}`);
if (req.method === 'POST' && url.pathname === '/api/message') {
handlePost(req, res);
} else if (req.method === 'GET' && url.pathname === '/api/messages') {
handlePoll(url, res);
} else {
res
.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' })
.end(JSON.stringify({ error: 'Not found' }));
}
});
await new Promise<void>((resolve, reject) => {
server!.once('error', reject);
server!.listen(opts.port, '127.0.0.1', () => {
log.info('Emacs channel listening', { port: opts.port, platformId: opts.platformId });
resolve();
});
});
// Stamp a human-readable name on the messaging_groups row on first boot.
config.onMetadata(opts.platformId, 'Emacs', false);
},
async teardown(): Promise<void> {
if (!server) return;
await new Promise<void>((resolve) => server!.close(() => resolve()));
server = null;
log.info('Emacs channel stopped');
},
isConnected(): boolean {
return server?.listening ?? false;
},
async deliver(platformId: string, _threadId: string | null, message: OutboundMessage): Promise<string | undefined> {
if (platformId !== opts.platformId) {
log.warn('Emacs deliver called with unknown platformId', { platformId });
return undefined;
}
const text = extractText(message.content);
if (!text) return undefined;
const id = `emacs-out-${Date.now()}`;
outboundBuffer.push({ text, timestamp: Date.now() });
while (outboundBuffer.length > OUTBOUND_BUFFER_MAX) outboundBuffer.shift();
return id;
},
};
}
function extractText(content: unknown): string {
if (typeof content === 'string') return content;
if (content && typeof content === 'object') {
const c = content as { text?: unknown };
if (typeof c.text === 'string') return c.text;
}
return '';
}
registerChannelAdapter('emacs', {
factory: () => {
const env = readEnvFile(['EMACS_ENABLED', 'EMACS_CHANNEL_PORT', 'EMACS_AUTH_TOKEN', 'EMACS_PLATFORM_ID']);
const enabled = process.env.EMACS_ENABLED || env.EMACS_ENABLED;
if (!enabled || enabled === 'false') return null;
const portStr = process.env.EMACS_CHANNEL_PORT || env.EMACS_CHANNEL_PORT || '8766';
const port = parseInt(portStr, 10);
const authToken = process.env.EMACS_AUTH_TOKEN || env.EMACS_AUTH_TOKEN || null;
const platformId = process.env.EMACS_PLATFORM_ID || env.EMACS_PLATFORM_ID || 'default';
return createEmacsAdapter({ port, authToken, platformId });
},
defaults: EMACS_DEFAULTS,
});
export { createEmacsAdapter };
-34
View File
@@ -1,34 +0,0 @@
/**
* Integration test for the gchat channel's single reach-in: the self-registration
* import in the `src/channels/index.ts` barrel. Importing the barrel runs gchat.ts's
* top-level `registerChannelAdapter('gchat', …)`; without the import the channel is
* silently absent.
*
* Behavior, not structural: it imports the real barrel and asserts the registry
* actually contains the channel. This reflects what happens at host boot if the
* `import './gchat.js';` line is deleted, or the barrel fails to evaluate for any
* reason (so the channel genuinely would not register), this goes red. A structural
* check of the import line would falsely pass in that second case.
*
* Importing the barrel is safe: registration is a pure top-level call, and gchat.ts
* builds the SDK adapter / bridge only inside its factory (invoked at host startup),
* never at import. It does require the adapter package (`@chat-adapter/gchat`) to be installed,
* which holds in a composed install: the skill's `pnpm install` step runs before this
* test so this test also implicitly guards that dependency (an unmocked import throws
* if the package is missing).
*
* gchat is a Chat SDK channel: gchat.ts also consumes a load-bearing *core* API
* `createChatSdkBridge(...)` from ./chat-sdk-bridge.js. That core-consumption is a
* typed call, so the build/typecheck leg (`pnpm run build`) guards it against upstream
* drift, not this test. Every Chat SDK channel follows this same shape.
*/
import { describe, it, expect } from 'vitest';
import { getRegisteredChannelNames } from './channel-registry.js';
import './index.js'; // the real barrel — triggers every channel's self-registration
describe('gchat channel registration', () => {
it('registers gchat via the channel barrel', () => {
expect(getRegisteredChannelNames()).toContain('gchat');
});
});
-37
View File
@@ -1,37 +0,0 @@
/**
* Google Chat channel adapter (v2) uses Chat SDK bridge.
* Self-registers on import.
*/
import { createGoogleChatAdapter } from '@chat-adapter/gchat';
import { readEnvFile } from '../env.js';
import type { ChannelDefaults } from './adapter.js';
import { createChatSdkBridge } from './chat-sdk-bridge.js';
import { registerChannelAdapter } from './channel-registry.js';
/**
* Dedicated bot app on a threaded platform. 'mention' (not sticky) is the
* conservative group default; operators upgrade per wiring.
*/
const GCHAT_DEFAULTS: ChannelDefaults = {
dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'request_approval' },
group: { engageMode: 'mention', threads: true, unknownSenderPolicy: 'request_approval' },
mentions: 'platform',
};
registerChannelAdapter('gchat', {
factory: () => {
const env = readEnvFile(['GCHAT_CREDENTIALS']);
if (!env.GCHAT_CREDENTIALS) return null;
const gchatAdapter = createGoogleChatAdapter({
credentials: JSON.parse(env.GCHAT_CREDENTIALS),
});
return createChatSdkBridge({
adapter: gchatAdapter,
concurrency: 'concurrent',
supportsThreads: true,
defaults: GCHAT_DEFAULTS,
});
},
defaults: GCHAT_DEFAULTS,
});
-34
View File
@@ -1,34 +0,0 @@
/**
* Integration test for the github channel's single reach-in: the self-registration
* import in the `src/channels/index.ts` barrel. Importing the barrel runs github.ts's
* top-level `registerChannelAdapter('github', …)`; without the import the channel is
* silently absent.
*
* Behavior, not structural: it imports the real barrel and asserts the registry
* actually contains the channel. This reflects what happens at host boot if the
* `import './github.js';` line is deleted, or the barrel fails to evaluate for any
* reason (so the channel genuinely would not register), this goes red. A structural
* check of the import line would falsely pass in that second case.
*
* Importing the barrel is safe: registration is a pure top-level call, and github.ts
* builds the SDK adapter / bridge only inside its factory (invoked at host startup),
* never at import. It does require the adapter package (`@chat-adapter/github`) to be installed,
* which holds in a composed install: the skill's `pnpm install` step runs before this
* test so this test also implicitly guards that dependency (an unmocked import throws
* if the package is missing).
*
* github is a Chat SDK channel: github.ts also consumes a load-bearing *core* API
* `createChatSdkBridge(...)` from ./chat-sdk-bridge.js. That core-consumption is a
* typed call, so the build/typecheck leg (`pnpm run build`) guards it against upstream
* drift, not this test. Every Chat SDK channel follows this same shape.
*/
import { describe, it, expect } from 'vitest';
import { getRegisteredChannelNames } from './channel-registry.js';
import './index.js'; // the real barrel — triggers every channel's self-registration
describe('github channel registration', () => {
it('registers github via the channel barrel', () => {
expect(getRegisteredChannelNames()).toContain('github');
});
});
-41
View File
@@ -1,41 +0,0 @@
/**
* GitHub channel adapter (v2) uses Chat SDK bridge.
* PR comment threads as conversations.
* Self-registers on import.
*/
import { createGitHubAdapter } from '@chat-adapter/github';
import { readEnvFile } from '../env.js';
import type { ChannelDefaults } from './adapter.js';
import { createChatSdkBridge } from './chat-sdk-bridge.js';
import { registerChannelAdapter } from './channel-registry.js';
/**
* Dedicated bot identity. group threads:true every issue/PR comment thread
* is its own conversation and session (the long-standing GitHub behavior,
* now declared instead of forced by supportsThreads alone).
*/
const GITHUB_DEFAULTS: ChannelDefaults = {
dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'request_approval' },
group: { engageMode: 'mention', threads: true, unknownSenderPolicy: 'request_approval' },
mentions: 'platform',
};
registerChannelAdapter('github', {
factory: () => {
const env = readEnvFile(['GITHUB_TOKEN', 'GITHUB_WEBHOOK_SECRET', 'GITHUB_BOT_USERNAME']);
if (!env.GITHUB_TOKEN) return null;
const githubAdapter = createGitHubAdapter({
token: env.GITHUB_TOKEN,
webhookSecret: env.GITHUB_WEBHOOK_SECRET,
userName: env.GITHUB_BOT_USERNAME,
});
return createChatSdkBridge({
adapter: githubAdapter,
concurrency: 'queue',
supportsThreads: true,
defaults: GITHUB_DEFAULTS,
});
},
defaults: GITHUB_DEFAULTS,
});
@@ -1,34 +0,0 @@
/**
* Integration test for the imessage channel's single reach-in: the self-registration
* import in the `src/channels/index.ts` barrel. Importing the barrel runs imessage.ts's
* top-level `registerChannelAdapter('imessage', …)`; without the import the channel is
* silently absent.
*
* Behavior, not structural: it imports the real barrel and asserts the registry
* actually contains the channel. This reflects what happens at host boot if the
* `import './imessage.js';` line is deleted, or the barrel fails to evaluate for any
* reason (so the channel genuinely would not register), this goes red. A structural
* check of the import line would falsely pass in that second case.
*
* Importing the barrel is safe: registration is a pure top-level call, and imessage.ts
* builds the SDK adapter / bridge only inside its factory (invoked at host startup),
* never at import. It does require the adapter package (`chat-adapter-imessage`) to be installed,
* which holds in a composed install: the skill's `pnpm install` step runs before this
* test so this test also implicitly guards that dependency (an unmocked import throws
* if the package is missing).
*
* imessage is a Chat SDK channel: imessage.ts also consumes a load-bearing *core* API
* `createChatSdkBridge(...)` from ./chat-sdk-bridge.js. That core-consumption is a
* typed call, so the build/typecheck leg (`pnpm run build`) guards it against upstream
* drift, not this test. Every Chat SDK channel follows this same shape.
*/
import { describe, it, expect } from 'vitest';
import { getRegisteredChannelNames } from './channel-registry.js';
import './index.js'; // the real barrel — triggers every channel's self-registration
describe('imessage channel registration', () => {
it('registers imessage via the channel barrel', () => {
expect(getRegisteredChannelNames()).toContain('imessage');
});
});
-48
View File
@@ -1,48 +0,0 @@
/**
* iMessage channel adapter (v2) uses Chat SDK bridge.
* Supports local mode (macOS Full Disk Access) and remote mode (Photon API).
* Self-registers on import.
*/
import { createiMessageAdapter } from 'chat-adapter-imessage';
import { readEnvFile } from '../env.js';
import type { ChannelDefaults } from './adapter.js';
import { createChatSdkBridge } from './chat-sdk-bridge.js';
import { registerChannelAdapter } from './channel-registry.js';
/**
* The operator's personal Apple ID is a shared identity strangers DMing it
* reach the human, not the bot, so auto-create stays 'strict'. iMessage
* exposes no group-mention metadata ('dm-only'); group wirings default to a
* name-pattern trigger instead ({name} = agent group name).
*/
const IMESSAGE_DEFAULTS: ChannelDefaults = {
dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'strict' },
group: { engageMode: 'pattern', engagePattern: '\\b{name}\\b', threads: false, unknownSenderPolicy: 'strict' },
mentions: 'dm-only',
};
registerChannelAdapter('imessage', {
factory: () => {
const env = readEnvFile(['IMESSAGE_ENABLED', 'IMESSAGE_LOCAL', 'IMESSAGE_SERVER_URL', 'IMESSAGE_API_KEY']);
const isLocal = env.IMESSAGE_LOCAL !== 'false';
if (isLocal && !env.IMESSAGE_ENABLED) return null;
if (!isLocal && !env.IMESSAGE_SERVER_URL) return null;
const rawAdapter = createiMessageAdapter({
local: isLocal,
serverUrl: env.IMESSAGE_SERVER_URL,
apiKey: env.IMESSAGE_API_KEY,
});
// Polyfill channelIdFromThreadId (community adapter doesn't implement it)
const imessageAdapter = Object.assign(rawAdapter, {
channelIdFromThreadId: (threadId: string) => threadId,
});
return createChatSdkBridge({
adapter: imessageAdapter,
concurrency: 'concurrent',
supportsThreads: false,
defaults: IMESSAGE_DEFAULTS,
});
},
defaults: IMESSAGE_DEFAULTS,
});
+4 -55
View File
@@ -1,60 +1,9 @@
// Channel self-registration barrel.
// Each import triggers the channel module's registerChannelAdapter() call.
//
// The `channels` branch keeps this file fully populated — it's the
// fully-loaded, runnable branch. Individual `/add-<channel>` skills pull
// single files from this branch onto a user's install, appending their
// own import lines to a leaner barrel on main.
// Main ships with one default channel — `cli`, the always-on local-terminal
// channel. Other channel skills (/add-slack, /add-discord, /add-whatsapp,
// ...) copy their module from the `channels` branch and append a
// self-registration import below.
// cli — default channel that ships with main (always on, no credentials).
import './cli.js';
// discord
import './discord.js';
// slack
// import './slack.js';
// telegram
import './telegram.js';
// github
// import './github.js';
// linear
import './linear.js';
// google chat
// import './gchat.js';
// microsoft teams
// import './teams.js';
// whatsapp cloud api
// import './whatsapp-cloud.js';
// resend (email)
// import './resend.js';
// matrix
// import './matrix.js';
// webex
// import './webex.js';
// imessage
import './imessage.js';
// gmail (native, no Chat SDK)
// whatsapp (native, no Chat SDK)
import './whatsapp.js';
// signal (native, no Chat SDK — signal-cli TCP JSON-RPC daemon)
// import './signal.js';
// emacs (native HTTP bridge, no Chat SDK)
// import './emacs.js';
// deltachat (native, no Chat SDK)
// import './deltachat.js'
-34
View File
@@ -1,34 +0,0 @@
/**
* Integration test for the linear channel's single reach-in: the self-registration
* import in the `src/channels/index.ts` barrel. Importing the barrel runs linear.ts's
* top-level `registerChannelAdapter('linear', …)`; without the import the channel is
* silently absent.
*
* Behavior, not structural: it imports the real barrel and asserts the registry
* actually contains the channel. This reflects what happens at host boot if the
* `import './linear.js';` line is deleted, or the barrel fails to evaluate for any
* reason (so the channel genuinely would not register), this goes red. A structural
* check of the import line would falsely pass in that second case.
*
* Importing the barrel is safe: registration is a pure top-level call, and linear.ts
* builds the SDK adapter / bridge only inside its factory (invoked at host startup),
* never at import. It does require the adapter package (`@chat-adapter/linear`) to be installed,
* which holds in a composed install: the skill's `pnpm install` step runs before this
* test so this test also implicitly guards that dependency (an unmocked import throws
* if the package is missing).
*
* linear is a Chat SDK channel: linear.ts also consumes a load-bearing *core* API
* `createChatSdkBridge(...)` from ./chat-sdk-bridge.js. That core-consumption is a
* typed call, so the build/typecheck leg (`pnpm run build`) guards it against upstream
* drift, not this test. Every Chat SDK channel follows this same shape.
*/
import { describe, it, expect } from 'vitest';
import { getRegisteredChannelNames } from './channel-registry.js';
import './index.js'; // the real barrel — triggers every channel's self-registration
describe('linear channel registration', () => {
it('registers linear via the channel barrel', () => {
expect(getRegisteredChannelNames()).toContain('linear');
});
});
-65
View File
@@ -1,65 +0,0 @@
/**
* Linear channel adapter (v2) uses Chat SDK bridge.
* Issue comment threads as conversations.
* Self-registers on import.
*
* Linear OAuth apps can't be @-mentioned, so this adapter relies on the
* bridge's default onNewMessage catch-all to forward every comment.
*/
import { createLinearAdapter } from '@chat-adapter/linear';
import { readEnvFile } from '../env.js';
import type { ChannelDefaults } from './adapter.js';
import { createChatSdkBridge } from './chat-sdk-bridge.js';
import { registerChannelAdapter } from './channel-registry.js';
/**
* Linear OAuth apps can't be @-mentioned (see module doc), so mention modes
* can never fire creation surfaces must refuse them ('never'). Group
* wirings default to pattern '.' (every comment engages) with per-issue
* threads. Auto-create can't fire without isMention, so unknownSenderPolicy
* is declared for uniformity only.
*/
const LINEAR_DEFAULTS: ChannelDefaults = {
dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'request_approval' },
group: { engageMode: 'pattern', engagePattern: '.', threads: true, unknownSenderPolicy: 'request_approval' },
mentions: 'never',
};
registerChannelAdapter('linear', {
factory: () => {
const env = readEnvFile([
'LINEAR_API_KEY',
'LINEAR_CLIENT_ID',
'LINEAR_CLIENT_SECRET',
'LINEAR_WEBHOOK_SECRET',
'LINEAR_BOT_USERNAME',
'LINEAR_TEAM_KEY',
]);
if (!env.LINEAR_API_KEY && !env.LINEAR_CLIENT_ID) return null;
const auth = env.LINEAR_CLIENT_ID
? { clientId: env.LINEAR_CLIENT_ID, clientSecret: env.LINEAR_CLIENT_SECRET }
: { apiKey: env.LINEAR_API_KEY };
const linearAdapter = createLinearAdapter({
...auth,
webhookSecret: env.LINEAR_WEBHOOK_SECRET,
userName: env.LINEAR_BOT_USERNAME,
});
// Override channelIdFromThreadId to return a team-based channel ID.
// The upstream adapter returns per-issue UUIDs which creates a new
// messaging group for every issue. We want one group per team.
const teamKey = env.LINEAR_TEAM_KEY || 'default';
linearAdapter.channelIdFromThreadId = () => `linear:${teamKey}`;
return createChatSdkBridge({
adapter: linearAdapter,
concurrency: 'queue',
supportsThreads: true,
defaults: LINEAR_DEFAULTS,
});
},
defaults: LINEAR_DEFAULTS,
});
-34
View File
@@ -1,34 +0,0 @@
/**
* Integration test for the matrix channel's single reach-in: the self-registration
* import in the `src/channels/index.ts` barrel. Importing the barrel runs matrix.ts's
* top-level `registerChannelAdapter('matrix', …)`; without the import the channel is
* silently absent.
*
* Behavior, not structural: it imports the real barrel and asserts the registry
* actually contains the channel. This reflects what happens at host boot if the
* `import './matrix.js';` line is deleted, or the barrel fails to evaluate for any
* reason (so the channel genuinely would not register), this goes red. A structural
* check of the import line would falsely pass in that second case.
*
* Importing the barrel is safe: registration is a pure top-level call, and matrix.ts
* builds the SDK adapter / bridge only inside its factory (invoked at host startup),
* never at import. It does require the adapter package (`@beeper/chat-adapter-matrix`) to be installed,
* which holds in a composed install: the skill's `pnpm install` step runs before this
* test so this test also implicitly guards that dependency (an unmocked import throws
* if the package is missing).
*
* matrix is a Chat SDK channel: matrix.ts also consumes a load-bearing *core* API
* `createChatSdkBridge(...)` from ./chat-sdk-bridge.js. That core-consumption is a
* typed call, so the build/typecheck leg (`pnpm run build`) guards it against upstream
* drift, not this test. Every Chat SDK channel follows this same shape.
*/
import { describe, it, expect } from 'vitest';
import { getRegisteredChannelNames } from './channel-registry.js';
import './index.js'; // the real barrel — triggers every channel's self-registration
describe('matrix channel registration', () => {
it('registers matrix via the channel barrel', () => {
expect(getRegisteredChannelNames()).toContain('matrix');
});
});
-225
View File
@@ -1,225 +0,0 @@
/**
* Matrix channel adapter (v2) uses Chat SDK bridge.
* Self-registers on import.
*
* Supports two auth methods (resolved by the adapter from env):
* - Access token: MATRIX_ACCESS_TOKEN + MATRIX_USER_ID
* - Password: MATRIX_USERNAME + MATRIX_PASSWORD (+ optional MATRIX_USER_ID)
*
* Optional env vars:
* MATRIX_BOT_USERNAME display name for the bot (default: "bot")
* MATRIX_INVITE_AUTOJOIN "true" to auto-accept room invites
* MATRIX_INVITE_AUTOJOIN_ALLOWLIST comma-separated user IDs allowed to invite
* MATRIX_RECOVERY_KEY enable E2EE cross-signing
* MATRIX_DEVICE_ID stable device ID across restarts
*/
import { createMatrixAdapter } from '@beeper/chat-adapter-matrix';
import { log } from '../log.js';
import { readEnvFile } from '../env.js';
import type { ChannelDefaults } from './adapter.js';
import { createChatSdkBridge } from './chat-sdk-bridge.js';
import { registerChannelAdapter } from './channel-registry.js';
/**
* Assumes a dedicated bot account on a homeserver (the common install).
* Non-threaded at the bridge level, so group engagement is 'mention', never
* sticky. Personal-account installs should edit their copy to dm 'strict'
* install-wide changes live in this declaration by design.
*/
const MATRIX_DEFAULTS: ChannelDefaults = {
dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'request_approval' },
group: { engageMode: 'mention', threads: false, unknownSenderPolicy: 'request_approval' },
mentions: 'platform',
};
const ENV_KEYS = [
'MATRIX_BASE_URL',
'MATRIX_ACCESS_TOKEN',
'MATRIX_USERNAME',
'MATRIX_PASSWORD',
'MATRIX_USER_ID',
'MATRIX_BOT_USERNAME',
'MATRIX_DEVICE_ID',
'MATRIX_RECOVERY_KEY',
'MATRIX_INVITE_AUTOJOIN',
'MATRIX_INVITE_AUTOJOIN_ALLOWLIST',
] as const;
/**
* Wrap the Matrix adapter so DM conversations are identified by user handle
* across the whole system, not by ephemeral room IDs.
*
* Matrix DMs live in rooms (e.g. "!abc:server"), but NanoClaw identifies
* channels by platform_id. Using a user handle as platform_id means both
* the user and the messaging group reference the same stable identifier.
*
* Two directions to bridge:
* - Outbound: delivery passes "matrix:@user:server" resolve to room via openDM
* - Inbound: adapter emits "matrix:!room:server" rewrite to user handle
* so the router finds the existing messaging group instead of creating
* a new one.
*
* Both resolutions are cached for the process lifetime.
*/
function wrapWithDmResolution(adapter: ReturnType<typeof createMatrixAdapter>): typeof adapter {
const origPostMessage = adapter.postMessage.bind(adapter);
const origStartTyping = adapter.startTyping.bind(adapter);
const origChannelIdFromThreadId = adapter.channelIdFromThreadId.bind(adapter);
// roomId → user handle, used to rewrite inbound channel IDs.
const roomToUserCache = new Map<string, string>();
function isUserHandle(threadId: string): boolean {
try {
const { roomID } = adapter.decodeThreadId(threadId);
return !roomID.startsWith('!');
} catch {
return true;
}
}
async function resolveThreadId(threadId: string): Promise<string> {
if (!isUserHandle(threadId)) return threadId;
const userHandle = threadId.startsWith('matrix:') ? threadId.slice('matrix:'.length) : threadId;
log.info('Matrix: resolving DM room for user handle', { userHandle });
const resolved = await adapter.openDM(userHandle);
try {
const { roomID } = adapter.decodeThreadId(resolved);
roomToUserCache.set(roomID, userHandle);
} catch {
// decode failure is non-fatal — outbound still works
}
return resolved;
}
// Rewrite inbound room-based channel IDs to user-handle form for DM rooms.
// Non-DM rooms pass through unchanged.
adapter.channelIdFromThreadId = (threadId: string): string => {
try {
const { roomID } = adapter.decodeThreadId(threadId);
if (!roomID.startsWith('!')) return origChannelIdFromThreadId(threadId);
const cached = roomToUserCache.get(roomID);
if (cached) return `matrix:${cached}`;
// Not cached — check if this is a DM by membership count
const client = (adapter as any).client;
const room = client?.getRoom(roomID);
if (!room) return origChannelIdFromThreadId(threadId);
if (room.getJoinedMemberCount() > 2) return origChannelIdFromThreadId(threadId);
const botId = (adapter as any).userID;
const otherMember = room.getJoinedMembers().find((m: { userId: string }) => m.userId !== botId);
if (!otherMember) return origChannelIdFromThreadId(threadId);
roomToUserCache.set(roomID, otherMember.userId);
return `matrix:${otherMember.userId}`;
} catch {
return origChannelIdFromThreadId(threadId);
}
};
// The Chat SDK calls adapter.isDM(threadId) synchronously to decide whether
// to dispatch to onDirectMessage handlers. The Matrix adapter doesn't expose
// this method — it only has an async isDirectRoom(). We add a synchronous
// isDM that checks room membership count: 2 members = DM.
(adapter as any).isDM = (threadId: string): boolean => {
try {
const { roomID } = adapter.decodeThreadId(threadId);
const client = (adapter as any).client;
if (!client) return false;
const room = client.getRoom(roomID);
if (!room) return false;
const members = room.getJoinedMemberCount();
return members <= 2;
} catch {
return false;
}
};
adapter.postMessage = async (
threadId: string,
...args: Parameters<typeof origPostMessage> extends [string, ...infer R] ? R : never
) => {
const resolvedTid = await resolveThreadId(threadId);
return origPostMessage(resolvedTid, ...args);
};
adapter.startTyping = async (threadId: string) => {
const resolvedTid = await resolveThreadId(threadId);
return origStartTyping(resolvedTid);
};
return adapter;
}
registerChannelAdapter('matrix', {
factory: () => {
const env = readEnvFile([...ENV_KEYS]);
if (!env.MATRIX_BASE_URL) return null;
if (!env.MATRIX_ACCESS_TOKEN && !(env.MATRIX_USERNAME && env.MATRIX_PASSWORD)) return null;
for (const key of ENV_KEYS) {
if (env[key]) process.env[key] = env[key];
}
// Default: auto-join room invites so DMs work without manual acceptance
if (!process.env.MATRIX_INVITE_AUTOJOIN) {
process.env.MATRIX_INVITE_AUTOJOIN = 'true';
}
const matrixAdapter = wrapWithDmResolution(createMatrixAdapter());
const bridge = createChatSdkBridge({
adapter: matrixAdapter,
concurrency: 'concurrent',
supportsThreads: false,
defaults: MATRIX_DEFAULTS,
});
// Matrix user IDs contain ":" (e.g. "@user:matrix.org") which the shared
// permissions module interprets as already-prefixed. Wrap onInbound to
// ensure senderId always carries the "matrix:" channel prefix so user
// records match between init-first-agent and inbound routing.
const origSetup = bridge.setup.bind(bridge);
bridge.setup = async (hostConfig) => {
const origOnInbound = hostConfig.onInbound.bind(hostConfig);
await origSetup({
...hostConfig,
onInbound: (platformId, threadId, message) => {
if (message.content && typeof message.content === 'object') {
const content = message.content as Record<string, unknown>;
if (typeof content.senderId === 'string' && !content.senderId.startsWith('matrix:')) {
content.senderId = `matrix:${content.senderId}`;
}
}
return origOnInbound(platformId, threadId, message);
},
});
// Wait for Matrix sync to reach PREPARED state before returning from setup.
// Without this, the host's delivery poll and sweep timer start immediately
// and can starve the SDK's sync generator microtask queue, blocking
// incremental syncs so new inbound messages never get dispatched.
await new Promise<void>((resolve) => {
const check = setInterval(() => {
if ((matrixAdapter as unknown as { liveSyncReady?: boolean }).liveSyncReady) {
log.info('Matrix sync ready');
clearInterval(check);
resolve();
}
}, 500);
setTimeout(() => {
clearInterval(check);
resolve();
}, 30_000);
});
};
return bridge;
},
defaults: MATRIX_DEFAULTS,
});
-34
View File
@@ -1,34 +0,0 @@
/**
* Integration test for the resend channel's single reach-in: the self-registration
* import in the `src/channels/index.ts` barrel. Importing the barrel runs resend.ts's
* top-level `registerChannelAdapter('resend', …)`; without the import the channel is
* silently absent.
*
* Behavior, not structural: it imports the real barrel and asserts the registry
* actually contains the channel. This reflects what happens at host boot if the
* `import './resend.js';` line is deleted, or the barrel fails to evaluate for any
* reason (so the channel genuinely would not register), this goes red. A structural
* check of the import line would falsely pass in that second case.
*
* Importing the barrel is safe: registration is a pure top-level call, and resend.ts
* builds the SDK adapter / bridge only inside its factory (invoked at host startup),
* never at import. It does require the adapter package (`@resend/chat-sdk-adapter`) to be installed,
* which holds in a composed install: the skill's `pnpm install` step runs before this
* test so this test also implicitly guards that dependency (an unmocked import throws
* if the package is missing).
*
* resend is a Chat SDK channel: resend.ts also consumes a load-bearing *core* API
* `createChatSdkBridge(...)` from ./chat-sdk-bridge.js. That core-consumption is a
* typed call, so the build/typecheck leg (`pnpm run build`) guards it against upstream
* drift, not this test. Every Chat SDK channel follows this same shape.
*/
import { describe, it, expect } from 'vitest';
import { getRegisteredChannelNames } from './channel-registry.js';
import './index.js'; // the real barrel — triggers every channel's self-registration
describe('resend channel registration', () => {
it('registers resend via the channel barrel', () => {
expect(getRegisteredChannelNames()).toContain('resend');
});
});
-41
View File
@@ -1,41 +0,0 @@
/**
* Resend (email) channel adapter (v2) uses Chat SDK bridge.
* Self-registers on import.
*/
import { createResendAdapter } from '@resend/chat-sdk-adapter';
import { readEnvFile } from '../env.js';
import type { ChannelDefaults } from './adapter.js';
import { createChatSdkBridge } from './chat-sdk-bridge.js';
import { registerChannelAdapter } from './channel-registry.js';
/**
* Email: every conversation is effectively a DM addressed to the bot's
* address the group branch is inert but required by the type. 'dm-only'
* because email has no mention metadata.
*/
const RESEND_DEFAULTS: ChannelDefaults = {
dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'request_approval' },
group: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'request_approval' },
mentions: 'dm-only',
};
registerChannelAdapter('resend', {
factory: () => {
const env = readEnvFile(['RESEND_API_KEY', 'RESEND_FROM_ADDRESS', 'RESEND_FROM_NAME', 'RESEND_WEBHOOK_SECRET']);
if (!env.RESEND_API_KEY) return null;
const resendAdapter = createResendAdapter({
apiKey: env.RESEND_API_KEY,
fromAddress: env.RESEND_FROM_ADDRESS,
fromName: env.RESEND_FROM_NAME,
webhookSecret: env.RESEND_WEBHOOK_SECRET,
});
return createChatSdkBridge({
adapter: resendAdapter,
concurrency: 'queue',
supportsThreads: false,
defaults: RESEND_DEFAULTS,
});
},
defaults: RESEND_DEFAULTS,
});
-29
View File
@@ -1,29 +0,0 @@
/**
* Integration test for the signal channel's single reach-in: the self-registration
* import in the `src/channels/index.ts` barrel. Importing the barrel runs signal.ts's
* top-level `registerChannelAdapter('signal', …)`; without the import the channel is
* silently absent.
*
* Behavior, not structural: it imports the real barrel and asserts the registry
* actually contains the channel. This reflects what happens at host boot if the
* `import './signal.js';` line is deleted, or the barrel fails to evaluate for any
* reason (so the channel genuinely would not register), this goes red. A structural
* check of the import line would falsely pass in that second case.
*
* signal is a native adapter with no npm dependency (it drives the external signal-cli binary over a local TCP socket); it talks to signal-cli.
* Importing the barrel is safe: registration is a pure top-level call and signal.ts
* opens connections / spawns subprocesses only inside setup() (run at host startup),
* never at import. There is no adapter package to guard here this test guards the
* one barrel reach-in (red if `import './signal.js';` is deleted or the barrel fails
* to evaluate).
*/
import { describe, it, expect } from 'vitest';
import { getRegisteredChannelNames } from './channel-registry.js';
import './index.js'; // the real barrel — triggers every channel's self-registration
describe('signal channel registration', () => {
it('registers signal via the channel barrel', () => {
expect(getRegisteredChannelNames()).toContain('signal');
});
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-34
View File
@@ -1,34 +0,0 @@
/**
* Integration test for the slack channel's single reach-in: the self-registration
* import in the `src/channels/index.ts` barrel. Importing the barrel runs slack.ts's
* top-level `registerChannelAdapter('slack', …)`; without the import the channel is
* silently absent.
*
* Behavior, not structural: it imports the real barrel and asserts the registry
* actually contains the channel. This reflects what happens at host boot if the
* `import './slack.js';` line is deleted, or the barrel fails to evaluate for any
* reason (so the channel genuinely would not register), this goes red. A structural
* check of the import line would falsely pass in that second case.
*
* Importing the barrel is safe: registration is a pure top-level call, and slack.ts
* builds the SDK adapter / bridge only inside its factory (invoked at host startup),
* never at import. It does require the adapter package to be installed, which holds
* in a composed install: the skill's `pnpm install` step runs before this test.
*
* Note on the Chat SDK family: slack.ts also consumes a load-bearing *core* API
* `createChatSdkBridge(...)` from ./chat-sdk-bridge.js with a specific options
* shape. That core-consumption is a typed call, so the build/typecheck leg
* (`pnpm run build`) guards it against upstream drift, not this test. Every Chat SDK
* channel (discord, telegram, teams, gchat, webex, ) follows this same shape:
* swap the channel name below and the adapter package in the build.
*/
import { describe, it, expect } from 'vitest';
import { getRegisteredChannelNames } from './channel-registry.js';
import './index.js'; // the real barrel — triggers every channel's self-registration
describe('slack channel registration', () => {
it('registers slack via the channel barrel', () => {
expect(getRegisteredChannelNames()).toContain('slack');
});
});
-62
View File
@@ -1,62 +0,0 @@
/**
* Slack channel adapter (v2) uses Chat SDK bridge.
* Self-registers on import.
*
* Socket Mode opt-in: set SLACK_APP_TOKEN (xapp-) to receive events over an
* outbound WebSocket instead of an inbound HTTPS webhook.
*/
import { createSlackAdapter } from '@chat-adapter/slack';
import { readEnvFile } from '../env.js';
import type { ChannelDefaults } from './adapter.js';
import { createChatSdkBridge } from './chat-sdk-bridge.js';
import { registerChannelAdapter } from './channel-registry.js';
/**
* Dedicated bot app on a threaded platform. group threads:true keeps
* mention-sticky bounded engagement sticks per-thread, not forever.
* dm.threads:false is a deliberate policy choice, not a capability limit:
* Slack users can open sub-threads inside a DM, but by default the agent
* replies top-level and all DM sub-threads collapse into the one DM session.
* This declaration owns that judgment (it used to be hardcoded router
* behavior); operators who want in-thread DM replies override per wiring
* with `--threads true`.
*/
const SLACK_DEFAULTS: ChannelDefaults = {
dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'request_approval' },
group: { engageMode: 'mention-sticky', threads: true, unknownSenderPolicy: 'request_approval' },
mentions: 'platform',
};
registerChannelAdapter('slack', {
factory: () => {
const env = readEnvFile(['SLACK_BOT_TOKEN', 'SLACK_SIGNING_SECRET', 'SLACK_APP_TOKEN']);
if (!env.SLACK_BOT_TOKEN) return null;
// SLACK_APP_TOKEN (xapp-…) enables Socket Mode: events arrive over an
// outbound WebSocket, so no public HTTPS endpoint is required. When set,
// the signing secret is optional (Slack signs socket frames separately).
const useSocketMode = Boolean(env.SLACK_APP_TOKEN);
const slackAdapter = createSlackAdapter({
botToken: env.SLACK_BOT_TOKEN,
signingSecret: env.SLACK_SIGNING_SECRET,
appToken: env.SLACK_APP_TOKEN,
mode: useSocketMode ? 'socket' : 'webhook',
});
const bridge = createChatSdkBridge({
adapter: slackAdapter,
concurrency: 'concurrent',
supportsThreads: true,
defaults: SLACK_DEFAULTS,
});
bridge.resolveChannelName = async (platformId: string) => {
try {
const info = await slackAdapter.fetchThread(platformId);
return (info as { channelName?: string }).channelName ?? null;
} catch {
return null;
}
};
return bridge;
},
defaults: SLACK_DEFAULTS,
});
-34
View File
@@ -1,34 +0,0 @@
/**
* Integration test for the teams channel's single reach-in: the self-registration
* import in the `src/channels/index.ts` barrel. Importing the barrel runs teams.ts's
* top-level `registerChannelAdapter('teams', …)`; without the import the channel is
* silently absent.
*
* Behavior, not structural: it imports the real barrel and asserts the registry
* actually contains the channel. This reflects what happens at host boot if the
* `import './teams.js';` line is deleted, or the barrel fails to evaluate for any
* reason (so the channel genuinely would not register), this goes red. A structural
* check of the import line would falsely pass in that second case.
*
* Importing the barrel is safe: registration is a pure top-level call, and teams.ts
* builds the SDK adapter / bridge only inside its factory (invoked at host startup),
* never at import. It does require the adapter package (`@chat-adapter/teams`) to be installed,
* which holds in a composed install: the skill's `pnpm install` step runs before this
* test so this test also implicitly guards that dependency (an unmocked import throws
* if the package is missing).
*
* teams is a Chat SDK channel: teams.ts also consumes a load-bearing *core* API
* `createChatSdkBridge(...)` from ./chat-sdk-bridge.js. That core-consumption is a
* typed call, so the build/typecheck leg (`pnpm run build`) guards it against upstream
* drift, not this test. Every Chat SDK channel follows this same shape.
*/
import { describe, it, expect } from 'vitest';
import { getRegisteredChannelNames } from './channel-registry.js';
import './index.js'; // the real barrel — triggers every channel's self-registration
describe('teams channel registration', () => {
it('registers teams via the channel barrel', () => {
expect(getRegisteredChannelNames()).toContain('teams');
});
});
-40
View File
@@ -1,40 +0,0 @@
/**
* Microsoft Teams channel adapter (v2) uses Chat SDK bridge.
* Self-registers on import.
*/
import { createTeamsAdapter } from '@chat-adapter/teams';
import { readEnvFile } from '../env.js';
import type { ChannelDefaults } from './adapter.js';
import { createChatSdkBridge } from './chat-sdk-bridge.js';
import { registerChannelAdapter } from './channel-registry.js';
/**
* Dedicated bot app on a threaded platform. 'mention' (not sticky) is the
* conservative group default; operators upgrade per wiring.
*/
const TEAMS_DEFAULTS: ChannelDefaults = {
dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'request_approval' },
group: { engageMode: 'mention', threads: true, unknownSenderPolicy: 'request_approval' },
mentions: 'platform',
};
registerChannelAdapter('teams', {
factory: () => {
const env = readEnvFile(['TEAMS_APP_ID', 'TEAMS_APP_PASSWORD', 'TEAMS_APP_TENANT_ID', 'TEAMS_APP_TYPE']);
if (!env.TEAMS_APP_ID) return null;
const teamsAdapter = createTeamsAdapter({
appId: env.TEAMS_APP_ID,
appPassword: env.TEAMS_APP_PASSWORD,
appType: (env.TEAMS_APP_TYPE as 'SingleTenant' | 'MultiTenant') || undefined,
appTenantId: env.TEAMS_APP_TENANT_ID || undefined,
});
return createChatSdkBridge({
adapter: teamsAdapter,
concurrency: 'concurrent',
supportsThreads: true,
defaults: TEAMS_DEFAULTS,
});
},
defaults: TEAMS_DEFAULTS,
});
@@ -1,78 +0,0 @@
import { describe, it, expect } from 'vitest';
import { sanitizeTelegramLegacyMarkdown } from './telegram-markdown-sanitize.js';
describe('sanitizeTelegramLegacyMarkdown', () => {
it('downgrades CommonMark **bold** to legacy *bold*', () => {
expect(sanitizeTelegramLegacyMarkdown('**Host path**')).toBe('*Host path*');
});
it('downgrades CommonMark __bold__ to legacy _italic_', () => {
expect(sanitizeTelegramLegacyMarkdown('__label__')).toBe('_label_');
});
it('leaves balanced legacy *bold* and _italic_ alone', () => {
expect(sanitizeTelegramLegacyMarkdown('a *b* c _d_ e')).toBe('a *b* c _d_ e');
});
it('preserves inline code spans untouched', () => {
const input = 'see `file_name.py` and `**not bold**` here';
expect(sanitizeTelegramLegacyMarkdown(input)).toBe(input);
});
it('preserves fenced code blocks untouched', () => {
const input = '```\nfoo_bar **baz**\n```';
expect(sanitizeTelegramLegacyMarkdown(input)).toBe(input);
});
it('strips formatting chars on odd delimiter count (unbalanced *)', () => {
expect(sanitizeTelegramLegacyMarkdown('a * b *c*')).toBe('a b c');
});
it('strips formatting chars on odd delimiter count (unbalanced _)', () => {
expect(sanitizeTelegramLegacyMarkdown('file_name has _one italic_')).toBe('filename has one italic');
});
it('strips brackets when unbalanced', () => {
expect(sanitizeTelegramLegacyMarkdown('see [docs here')).toBe('see docs here');
});
it('leaves matched brackets (e.g. links) alone when counts balance', () => {
const input = 'see [docs](https://example.com) for more';
expect(sanitizeTelegramLegacyMarkdown(input)).toBe(input);
});
it('fixes the real failing message', () => {
const input =
'Sure! What do you want to mount, and where should it appear inside the container?\n\n' +
'- **Host path** (on your machine): e.g. `~/projects/webapp`\n' +
'- **Container path**: e.g. `workspace/webapp`\n' +
'- **Read-only or read-write?**';
const out = sanitizeTelegramLegacyMarkdown(input);
expect(out).not.toContain('**');
expect(out).toContain('*Host path*');
expect(out).toContain('`~/projects/webapp`');
expect((out.match(/\*/g) ?? []).length % 2).toBe(0);
});
it('is a no-op on empty string', () => {
expect(sanitizeTelegramLegacyMarkdown('')).toBe('');
});
it('replaces dash list bullets with • so the adapter does not re-emit `*` markers', () => {
expect(sanitizeTelegramLegacyMarkdown('- one\n- two')).toBe('• one\n• two');
});
it('preserves indented list structure', () => {
expect(sanitizeTelegramLegacyMarkdown(' - nested')).toBe(' • nested');
});
it('flattens Markdown horizontal rules (---, ***, ___)', () => {
const input = 'before\n---\n***\n___\nafter';
expect(sanitizeTelegramLegacyMarkdown(input)).toBe('before\n⎯⎯⎯\n⎯⎯⎯\n⎯⎯⎯\nafter');
});
it('leaves horizontal rules inside code blocks alone', () => {
const input = '```\n---\n```';
expect(sanitizeTelegramLegacyMarkdown(input)).toBe(input);
});
});
@@ -1,55 +0,0 @@
/**
* Sanitize outbound text for Telegram's legacy `Markdown` parse mode.
*
* WORKAROUND: The @chat-adapter/telegram adapter hardcodes parse_mode=Markdown
* (legacy) but its converter emits CommonMark. Messages with `**bold**`, odd
* delimiter counts, or malformed links are rejected by Telegram and dropped
* after retries. Remove this once upstream ships real mode-aware conversion
* (vercel/chat PR #367 adds the knob; a follow-up is needed for the converter).
*/
const CODE_PATTERN = /```[\s\S]*?```|`[^`\n]*`/g;
const PLACEHOLDER_PREFIX = '\x00CODE';
const PLACEHOLDER_SUFFIX = '\x00';
export function sanitizeTelegramLegacyMarkdown(input: string): string {
if (!input) return input;
const codeSegments: string[] = [];
let text = input.replace(CODE_PATTERN, (m) => {
codeSegments.push(m);
return `${PLACEHOLDER_PREFIX}${codeSegments.length - 1}${PLACEHOLDER_SUFFIX}`;
});
// The adapter re-parses and re-stringifies markdown before sending, which
// rewrites `- item` list bullets into `* item` — injecting unbalanced
// asterisks that Telegram's legacy Markdown parser then rejects. Replace
// list bullets with a plain Unicode bullet so the adapter treats the line
// as prose.
text = text.replace(/^(\s*)[-+]\s+/gm, '$1• ');
// Flatten Markdown horizontal rules (bare --- / *** / ___ lines) to a
// plain Unicode divider. The parser doesn't understand HR syntax and the
// `*` / `_` characters would otherwise unbalance the delimiter counts below.
text = text.replace(/^[ \t]*[-_*]{3,}[ \t]*$/gm, '⎯⎯⎯');
text = text.replace(/\*\*([^*\n]+?)\*\*/g, '*$1*');
text = text.replace(/__([^_\n]+?)__/g, '_$1_');
const starCount = (text.match(/\*/g) ?? []).length;
const underCount = (text.match(/_/g) ?? []).length;
if (starCount % 2 !== 0 || underCount % 2 !== 0) {
text = text.replace(/[*_]/g, '');
}
const openBrackets = (text.match(/\[/g) ?? []).length;
const closeBrackets = (text.match(/\]/g) ?? []).length;
if (openBrackets !== closeBrackets) {
text = text.replace(/[[\]]/g, '');
}
return text.replace(
new RegExp(`${PLACEHOLDER_PREFIX}(\\d+)${PLACEHOLDER_SUFFIX}`, 'g'),
(_, i) => codeSegments[Number(i)],
);
}
-248
View File
@@ -1,248 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import fs from 'fs';
import path from 'path';
import os from 'os';
vi.mock('../log.js', () => ({ log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } }));
import {
createPairing,
tryConsume,
getStatus,
getPairing,
waitForPairing,
extractCode,
extractAddressedText,
_setStorePathForTest,
_resetForTest,
} from './telegram-pairing.js';
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tg-pair-'));
_setStorePathForTest(path.join(tmpDir, 'pairings.json'));
});
afterEach(() => {
_resetForTest();
_setStorePathForTest(null);
fs.rmSync(tmpDir, { recursive: true, force: true });
});
describe('extractAddressedText', () => {
it('strips @botname prefix', () => {
expect(extractAddressedText('@nanobot 1234', 'nanobot')).toBe('1234');
});
it('is case-insensitive', () => {
expect(extractAddressedText('@NanoBot hello', 'nanobot')).toBe('hello');
});
it('returns null when not addressed', () => {
expect(extractAddressedText('hello 1234', 'nanobot')).toBeNull();
});
it('returns null when address is mid-text', () => {
expect(extractAddressedText('hi @nanobot 1234', 'nanobot')).toBeNull();
});
});
describe('extractCode', () => {
it('accepts a bare 4-digit code', () => {
expect(extractCode('0349', 'nanobot')).toBe('0349');
});
it('accepts 4-digit code after @botname', () => {
expect(extractCode('@nanobot 0042', 'nanobot')).toBe('0042');
});
it('rejects non-4-digit numbers', () => {
expect(extractCode('@nanobot 12345', 'nanobot')).toBeNull();
expect(extractCode('@nanobot 12', 'nanobot')).toBeNull();
expect(extractCode('12345', 'nanobot')).toBeNull();
});
it('rejects loose matches with surrounding text', () => {
expect(extractCode('my pin is 0349', 'nanobot')).toBeNull();
expect(extractCode('0349 thanks', 'nanobot')).toBeNull();
});
});
describe('createPairing', () => {
it('generates a 4-digit code', async () => {
const r = await createPairing('main');
expect(r.code).toMatch(/^\d{4}$/);
expect(r.status).toBe('pending');
});
it('does not collide with active codes', async () => {
const codes = new Set<string>();
for (let i = 0; i < 20; i++) {
const r = await createPairing('main');
expect(codes.has(r.code)).toBe(false);
codes.add(r.code);
}
});
});
describe('tryConsume', () => {
it('matches and marks consumed', async () => {
const r = await createPairing('main');
const consumed = await tryConsume({
text: `@nanobot ${r.code}`,
botUsername: 'nanobot',
platformId: 'telegram:123',
isGroup: false,
adminUserId: 'u1',
});
expect(consumed).not.toBeNull();
expect(consumed!.status).toBe('consumed');
expect(consumed!.consumed?.platformId).toBe('telegram:123');
expect(consumed!.consumed?.adminUserId).toBe('u1');
expect(getStatus(r.code)).toBe('consumed');
});
it('returns null on no match (silent drop)', async () => {
await createPairing('main');
const out = await tryConsume({
text: '@nanobot 9999',
botUsername: 'nanobot',
platformId: 'x',
isGroup: false,
});
expect(out).toBeNull();
});
it('matches a bare code without @botname addressing', async () => {
const r = await createPairing('main');
const out = await tryConsume({
text: r.code,
botUsername: 'nanobot',
platformId: 'x',
isGroup: false,
});
expect(out).not.toBeNull();
expect(out!.status).toBe('consumed');
});
it('cannot be consumed twice', async () => {
const r = await createPairing('main');
await tryConsume({ text: `@b ${r.code}`, botUsername: 'b', platformId: 'p', isGroup: false });
const second = await tryConsume({ text: `@b ${r.code}`, botUsername: 'b', platformId: 'p', isGroup: false });
expect(second).toBeNull();
});
it('cannot consume an invalidated pairing', async () => {
const r = await createPairing('main');
// Invalidate by sending a wrong code
await tryConsume({ text: '9999', botUsername: 'b', platformId: 'p', isGroup: false });
const out = await tryConsume({ text: `@b ${r.code}`, botUsername: 'b', platformId: 'p', isGroup: false });
expect(out).toBeNull();
expect(getStatus(r.code)).toBe('invalidated');
});
});
describe('getStatus', () => {
it('returns unknown for missing codes', () => {
expect(getStatus('0000')).toBe('unknown');
});
});
describe('waitForPairing', () => {
it('resolves when consumed', async () => {
const r = await createPairing('main');
const p = waitForPairing(r.code, { pollMs: 50 });
setTimeout(() => {
tryConsume({ text: `@b ${r.code}`, botUsername: 'b', platformId: 'tg:1', isGroup: true, name: 'Group' });
}, 100);
const consumed = await p;
expect(consumed.status).toBe('consumed');
expect(consumed.consumed?.name).toBe('Group');
});
it('rejects on invalidation', async () => {
const r = await createPairing('main');
const waiter = waitForPairing(r.code, { pollMs: 30 });
setTimeout(() => {
tryConsume({ text: '0000', botUsername: 'b', platformId: 'tg:1', isGroup: false });
}, 60);
await expect(waiter).rejects.toThrow(/invalidated/);
});
});
describe('replace-by-default', () => {
it('supersedes an existing pending pairing with the same intent', async () => {
const first = await createPairing('main');
const second = await createPairing('main');
expect(getStatus(first.code)).toBe('invalidated');
expect(getStatus(second.code)).toBe('pending');
});
it('does not supersede pairings with a different intent', async () => {
const a = await createPairing({ kind: 'wire-to', folder: 'work' });
const b = await createPairing({ kind: 'wire-to', folder: 'side' });
expect(getStatus(a.code)).toBe('pending');
expect(getStatus(b.code)).toBe('pending');
});
it('causes waitForPairing on the old code to reject as invalidated', async () => {
const first = await createPairing('main');
const waiter = waitForPairing(first.code, { pollMs: 30 });
await new Promise((r) => setTimeout(r, 50));
await createPairing('main');
await expect(waiter).rejects.toThrow(/invalidated/);
});
});
describe('attempt tracking', () => {
it('fires onAttempt for a wrong code, invalidates the pairing, and rejects the waiter', async () => {
const r = await createPairing('main');
const attempts: string[] = [];
const waiter = waitForPairing(r.code, {
pollMs: 30,
onAttempt: (a) => attempts.push(a.candidate),
});
setTimeout(() => {
tryConsume({ text: '9999', botUsername: 'b', platformId: 'tg:1', isGroup: false });
}, 60);
await expect(waiter).rejects.toThrow(/invalidated by wrong code \(9999\)/);
expect(attempts).toEqual(['9999']);
expect(getStatus(r.code)).toBe('invalidated');
});
it('a correct code consumes without firing onAttempt', async () => {
const r = await createPairing('main');
const attempts: string[] = [];
const waiter = waitForPairing(r.code, {
pollMs: 30,
onAttempt: (a) => attempts.push(a.candidate),
});
setTimeout(() => {
tryConsume({ text: r.code, botUsername: 'b', platformId: 'tg:1', isGroup: false });
}, 60);
const consumed = await waiter;
expect(consumed.status).toBe('consumed');
expect(attempts).toEqual([]);
});
it('ignores non-code messages and keeps the pairing pending', async () => {
const r = await createPairing('main');
await tryConsume({ text: 'hello there', botUsername: 'b', platformId: 'p', isGroup: false });
const after = getPairing(r.code);
expect(after?.status).toBe('pending');
expect(after?.attempts ?? []).toHaveLength(0);
});
it('a second code attempt after invalidation does not match', async () => {
const r = await createPairing('main');
await tryConsume({ text: '9999', botUsername: 'b', platformId: 'p', isGroup: false });
const retry = await tryConsume({ text: r.code, botUsername: 'b', platformId: 'p', isGroup: false });
expect(retry).toBeNull();
});
});
describe('intent passthrough', () => {
it('preserves wire-to and new-agent intents', async () => {
const a = await createPairing({ kind: 'wire-to', folder: 'work' });
const b = await createPairing({ kind: 'new-agent', folder: 'side' });
const ca = await tryConsume({ text: `@b ${a.code}`, botUsername: 'b', platformId: 'p1', isGroup: true });
const cb = await tryConsume({ text: `@b ${b.code}`, botUsername: 'b', platformId: 'p2', isGroup: true });
expect(ca!.intent).toEqual({ kind: 'wire-to', folder: 'work' });
expect(cb!.intent).toEqual({ kind: 'new-agent', folder: 'side' });
});
});
-339
View File
@@ -1,339 +0,0 @@
/**
* Telegram pairing proves the operator owns the chat they're registering.
*
* BotFather hands out tokens with no user binding, so anyone who guesses the
* bot's username can DM it. Pairing closes that gap: setup creates a one-time
* 4-digit code and the operator echoes it back from the chat they want to
* register. The message must be exactly the 4 digits (optionally prefixed by
* `@botname ` for groups with privacy ON) arbitrary messages that happen to
* contain a 4-digit number do NOT match. The inbound interceptor in
* telegram.ts matches the code, records the chat, upserts the paired user,
* and (if no owner exists yet) promotes them to owner all before the
* message ever reaches the router.
*
* Storage is a JSON file at data/telegram-pairings.json single-process,
* read-modify-write under an in-process mutex.
*/
import fs from 'fs';
import path from 'path';
import { DATA_DIR } from '../config.js';
import { log } from '../log.js';
export type PairingIntent = 'main' | { kind: 'wire-to'; folder: string } | { kind: 'new-agent'; folder: string };
export type PairingStatus = 'pending' | 'consumed' | 'invalidated' | 'unknown';
export interface ConsumedDetails {
platformId: string;
isGroup: boolean;
name: string | null;
adminUserId: string | null;
consumedAt: string;
}
export interface PairingAttempt {
candidate: string;
platformId: string;
at: string;
matched: boolean;
}
export interface PairingRecord {
code: string;
intent: PairingIntent;
createdAt: string;
status: Exclude<PairingStatus, 'unknown'>;
consumed?: ConsumedDetails;
/** Recent pairing attempts observed while this record was pending. Capped. */
attempts?: PairingAttempt[];
}
const MAX_ATTEMPTS_PER_RECORD = 10;
function intentEquals(a: PairingIntent, b: PairingIntent): boolean {
if (a === 'main' || b === 'main') return a === b;
return a.kind === b.kind && a.folder === b.folder;
}
interface Store {
pairings: PairingRecord[];
}
/** Pairing codes do not expire — they are consumed on match or invalidated by wrong guesses. */
const FILE_NAME = 'telegram-pairings.json';
let storePathOverride: string | null = null;
export function _setStorePathForTest(p: string | null): void {
storePathOverride = p;
}
function storePath(): string {
return storePathOverride ?? path.join(DATA_DIR, FILE_NAME);
}
let mutex: Promise<unknown> = Promise.resolve();
function withLock<T>(fn: () => Promise<T> | T): Promise<T> {
const next = mutex.then(() => fn());
mutex = next.catch(() => {});
return next;
}
function readStore(): Store {
try {
const raw = fs.readFileSync(storePath(), 'utf8');
const parsed = JSON.parse(raw) as Store;
if (!Array.isArray(parsed.pairings)) return { pairings: [] };
return parsed;
} catch {
return { pairings: [] };
}
}
function writeStore(store: Store): void {
const p = storePath();
fs.mkdirSync(path.dirname(p), { recursive: true });
const tmp = `${p}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(store, null, 2));
fs.renameSync(tmp, p);
}
/** Clean up old consumed/invalidated records (keep last 50). */
function sweep(store: Store): boolean {
if (store.pairings.length <= 50) return false;
store.pairings = store.pairings.slice(-50);
return true;
}
function generateCode(active: Set<string>): string {
// 4-digit numeric, zero-padded. 10k space, fine for one-at-a-time intents.
for (let i = 0; i < 50; i++) {
const code = Math.floor(Math.random() * 10000)
.toString()
.padStart(4, '0');
if (!active.has(code)) return code;
}
throw new Error('Could not allocate a free pairing code (too many active).');
}
export async function createPairing(intent: PairingIntent): Promise<PairingRecord> {
return withLock(() => {
const store = readStore();
sweep(store);
// Replace-by-default: a new pairing for an intent supersedes any existing
// pending pairing for the same intent. Old waitForPairing calls observe
// `invalidated` and exit on their own.
for (const r of store.pairings) {
if (r.status === 'pending' && intentEquals(r.intent, intent)) {
r.status = 'invalidated';
log.info('Pairing superseded by new request', { code: r.code, intent });
}
}
const active = new Set(store.pairings.filter((r) => r.status === 'pending').map((r) => r.code));
const record: PairingRecord = {
code: generateCode(active),
intent,
createdAt: new Date().toISOString(),
status: 'pending',
};
store.pairings.push(record);
writeStore(store);
log.info('Pairing created', { code: record.code, intent });
return record;
});
}
export interface ConsumeInput {
text: string;
botUsername: string;
platformId: string;
isGroup: boolean;
name?: string | null;
adminUserId?: string | null;
}
/** Strip leading @botname and return the trimmed remainder, or null if not addressed. */
export function extractAddressedText(text: string, botUsername: string): string | null {
const trimmed = text.trim();
const re = new RegExp(`^@${botUsername.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&')}\\b`, 'i');
const m = trimmed.match(re);
if (!m) return null;
return trimmed.slice(m[0].length).trim();
}
/**
* Extract a pairing code from an inbound message. The message must be exactly
* 4 digits (optionally prefixed by `@botname `) loose matches like
* "my pin is 1234" are rejected to avoid false positives from chatter.
*/
export function extractCode(text: string, botUsername: string): string | null {
const addressed = extractAddressedText(text, botUsername);
const candidate = (addressed !== null ? addressed : text).trim();
const m = candidate.match(/^(\d{4})$/);
return m ? m[1] : null;
}
/**
* Try to match an inbound message against a pending pairing. On match,
* marks the pairing consumed atomically and returns the record. Returns
* null on no match or expiry (silent drop).
*/
export async function tryConsume(input: ConsumeInput): Promise<PairingRecord | null> {
const code = extractCode(input.text, input.botUsername);
if (!code) return null;
return withLock(() => {
const store = readStore();
const now = Date.now();
sweep(store);
const record = store.pairings.find((r) => r.code === code && r.status === 'pending');
if (!record) {
// Miss: record the attempt on every currently-pending record so each
// waitForPairing caller can surface it as user feedback.
const attempt: PairingAttempt = {
candidate: code,
platformId: input.platformId,
at: new Date(now).toISOString(),
matched: false,
};
let recorded = false;
for (const r of store.pairings) {
if (r.status !== 'pending') continue;
r.attempts = [...(r.attempts ?? []), attempt].slice(-MAX_ATTEMPTS_PER_RECORD);
// One attempt per code. A wrong guess invalidates the pairing
// immediately — pair-telegram observes the `invalidated` signal and
// auto-issues a fresh code (up to a retry cap).
r.status = 'invalidated';
recorded = true;
}
writeStore(store);
if (recorded) {
log.info('Pairing invalidated by wrong attempt', { candidate: code, platformId: input.platformId });
}
return null;
}
record.status = 'consumed';
record.consumed = {
platformId: input.platformId,
isGroup: input.isGroup,
name: input.name ?? null,
adminUserId: input.adminUserId ?? null,
consumedAt: new Date(now).toISOString(),
};
record.attempts = [
...(record.attempts ?? []),
{ candidate: code, platformId: input.platformId, at: new Date(now).toISOString(), matched: true },
].slice(-MAX_ATTEMPTS_PER_RECORD);
writeStore(store);
log.info('Pairing consumed', { code, platformId: input.platformId, intent: record.intent });
return record;
});
}
export function getStatus(code: string): PairingStatus {
const store = readStore();
sweep(store);
const r = store.pairings.find((p) => p.code === code);
if (!r) return 'unknown';
return r.status;
}
export function getPairing(code: string): PairingRecord | null {
const store = readStore();
sweep(store);
return store.pairings.find((p) => p.code === code) ?? null;
}
export interface WaitForPairingOptions {
/** Polling interval as a fallback when fs.watch misses an event. */
pollMs?: number;
/** Fires once per new attempt recorded against this pairing (misses only). */
onAttempt?: (attempt: PairingAttempt) => void;
}
/**
* Resolve when the pairing is consumed; reject when it is invalidated
* (wrong code guess). Waits indefinitely codes do not expire.
* Uses fs.watch as the primary signal with a slow poll fallback.
*/
export async function waitForPairing(code: string, opts: WaitForPairingOptions = {}): Promise<PairingRecord> {
const pollMs = opts.pollMs ?? 1000;
const initial = getPairing(code);
if (!initial) throw new Error(`Unknown pairing code: ${code}`);
return new Promise<PairingRecord>((resolve, reject) => {
let watcher: fs.FSWatcher | null = null;
let interval: NodeJS.Timeout | null = null;
let settled = false;
const cleanup = () => {
settled = true;
if (watcher)
try {
watcher.close();
} catch {
/* ignore */
}
if (interval) clearInterval(interval);
};
let seenAttempts = 0;
const check = () => {
if (settled) return;
const r = getPairing(code);
if (!r) {
cleanup();
reject(new Error(`Pairing ${code} disappeared`));
return;
}
// Surface any new miss attempts since the last tick. Only fire for
// misses — matches are signaled by `status === 'consumed'` below.
if (opts.onAttempt && r.attempts) {
for (let i = seenAttempts; i < r.attempts.length; i++) {
const a = r.attempts[i];
if (!a.matched) {
try {
opts.onAttempt(a);
} catch {
/* ignore */
}
}
}
seenAttempts = r.attempts.length;
}
if (r.status === 'consumed') {
cleanup();
resolve(r);
return;
}
if (r.status === 'invalidated') {
cleanup();
const lastMiss = r.attempts
?.slice()
.reverse()
.find((a) => !a.matched);
reject(new Error(`Pairing ${code} invalidated by wrong code${lastMiss ? ` (${lastMiss.candidate})` : ''}`));
return;
}
};
try {
const dir = path.dirname(storePath());
fs.mkdirSync(dir, { recursive: true });
watcher = fs.watch(dir, (_event, fname) => {
if (!fname || fname.toString().startsWith(path.basename(storePath()))) check();
});
} catch {
// fs.watch unsupported — poll-only is fine
}
interval = setInterval(check, pollMs);
check();
});
}
/** Test helper — wipe the store. */
export function _resetForTest(): void {
try {
fs.unlinkSync(storePath());
} catch {
// ignore
}
}
@@ -1,34 +0,0 @@
/**
* Integration test for the telegram channel's single reach-in: the self-registration
* import in the `src/channels/index.ts` barrel. Importing the barrel runs telegram.ts's
* top-level `registerChannelAdapter('telegram', …)`; without the import the channel is
* silently absent.
*
* Behavior, not structural: it imports the real barrel and asserts the registry
* actually contains the channel. This reflects what happens at host boot if the
* `import './telegram.js';` line is deleted, or the barrel fails to evaluate for any
* reason (so the channel genuinely would not register), this goes red. A structural
* check of the import line would falsely pass in that second case.
*
* Importing the barrel is safe: registration is a pure top-level call, and telegram.ts
* builds the SDK adapter / bridge only inside its factory (invoked at host startup),
* never at import. It does require the adapter package (`@chat-adapter/telegram`) to be installed,
* which holds in a composed install: the skill's `pnpm install` step runs before this
* test so this test also implicitly guards that dependency (an unmocked import throws
* if the package is missing).
*
* telegram is a Chat SDK channel: telegram.ts also consumes a load-bearing *core* API
* `createChatSdkBridge(...)` from ./chat-sdk-bridge.js. That core-consumption is a
* typed call, so the build/typecheck leg (`pnpm run build`) guards it against upstream
* drift, not this test. Every Chat SDK channel follows this same shape.
*/
import { describe, it, expect } from 'vitest';
import { getRegisteredChannelNames } from './channel-registry.js';
import './index.js'; // the real barrel — triggers every channel's self-registration
describe('telegram channel registration', () => {
it('registers telegram via the channel barrel', () => {
expect(getRegisteredChannelNames()).toContain('telegram');
});
});
-261
View File
@@ -1,261 +0,0 @@
/**
* Telegram channel adapter (v2) uses Chat SDK bridge, with a pairing
* interceptor wrapped around onInbound to verify chat ownership before
* registration. See telegram-pairing.ts for the why.
*/
import { createTelegramAdapter } from '@chat-adapter/telegram';
import { readEnvFile } from '../env.js';
import { log } from '../log.js';
import { createMessagingGroup, getMessagingGroupByPlatform, updateMessagingGroup } from '../db/messaging-groups.js';
import { grantRole, hasAnyOwner } from '../modules/permissions/db/user-roles.js';
import { upsertUser } from '../modules/permissions/db/users.js';
import { createChatSdkBridge, type ReplyContext } from './chat-sdk-bridge.js';
import { sanitizeTelegramLegacyMarkdown } from './telegram-markdown-sanitize.js';
import { registerChannelAdapter } from './channel-registry.js';
import type { ChannelAdapter, ChannelDefaults, ChannelSetup, InboundMessage } from './adapter.js';
import { tryConsume } from './telegram-pairing.js';
/**
* Dedicated bot identity, non-threaded platform (supportsThreads:false), so
* group engagement can never be sticky-per-thread 'mention' keeps a group
* wiring from staying engaged forever in the single shared session.
*/
const TELEGRAM_DEFAULTS: ChannelDefaults = {
dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'request_approval' },
group: { engageMode: 'mention', threads: false, unknownSenderPolicy: 'request_approval' },
mentions: 'platform',
};
/**
* Retry a one-shot operation that can fail on transient network errors at
* cold-start (DNS hiccups, brief upstream outages). Exponential backoff capped
* at 5 attempts if the network is truly down we surface it instead of
* hanging the service indefinitely.
*/
async function withRetry<T>(fn: () => Promise<T>, label: string, maxAttempts = 5): Promise<T> {
let lastErr: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (err) {
lastErr = err;
if (attempt === maxAttempts) break;
const delay = Math.min(16000, 1000 * 2 ** (attempt - 1));
log.warn('Telegram setup failed, retrying', { label, attempt, delayMs: delay, err });
await new Promise((r) => setTimeout(r, delay));
}
}
throw lastErr;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function extractReplyContext(raw: Record<string, any>): ReplyContext | null {
if (!raw.reply_to_message) return null;
const reply = raw.reply_to_message;
return {
text: reply.text || reply.caption || '',
sender: reply.from?.first_name || reply.from?.username || 'Unknown',
};
}
/** Look up the bot username via Telegram getMe. Cached after first call. */
async function fetchBotUsername(token: string): Promise<string | null> {
try {
const res = await fetch(`https://api.telegram.org/bot${token}/getMe`);
const json = (await res.json()) as { ok: boolean; result?: { username?: string } };
return json.ok ? (json.result?.username ?? null) : null;
} catch (err) {
log.warn('Telegram getMe failed', { err });
return null;
}
}
function isGroupPlatformId(platformId: string): boolean {
// platformId is "telegram:<chatId>". Negative chat IDs are groups/channels.
const id = platformId.split(':').pop() ?? '';
return id.startsWith('-');
}
interface InboundFields {
text: string;
authorUserId: string | null;
}
function readInboundFields(message: InboundMessage): InboundFields {
if (message.kind !== 'chat-sdk' || !message.content || typeof message.content !== 'object') {
return { text: '', authorUserId: null };
}
const c = message.content as { text?: string; author?: { userId?: string } };
return { text: c.text ?? '', authorUserId: c.author?.userId ?? null };
}
/**
* Build an onInbound interceptor that consumes pairing codes before they
* reach the router. On match: records the chat + its paired user, promotes
* the user to owner if the instance has no owner yet, and short-circuits.
* On miss: forwards to the host.
*/
/**
* Send a one-shot confirmation back to the paired chat. Best-effort failures
* are logged but never propagated, so a Telegram outage can't undo a successful
* pairing or trigger the interceptor's fail-open path.
*/
async function sendPairingConfirmation(token: string, platformId: string): Promise<void> {
const chatId = platformId.split(':').slice(1).join(':');
if (!chatId) return;
try {
const res = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
chat_id: chatId,
text: 'Pairing success! Head back to the NanoClaw installer to finish setup.',
}),
});
if (!res.ok) {
log.warn('Telegram pairing confirmation non-OK', { status: res.status });
}
} catch (err) {
log.warn('Telegram pairing confirmation failed', { err });
}
}
function createPairingInterceptor(
botUsernamePromise: Promise<string | null>,
hostOnInbound: ChannelSetup['onInbound'],
token: string,
): ChannelSetup['onInbound'] {
return async (platformId, threadId, message) => {
try {
const botUsername = await botUsernamePromise;
if (!botUsername) {
hostOnInbound(platformId, threadId, message);
return;
}
const { text, authorUserId } = readInboundFields(message);
if (!text) {
hostOnInbound(platformId, threadId, message);
return;
}
const consumed = await tryConsume({
text,
botUsername,
platformId,
isGroup: isGroupPlatformId(platformId),
adminUserId: authorUserId,
});
if (!consumed) {
hostOnInbound(platformId, threadId, message);
return;
}
// Pairing matched — record the chat and short-circuit so the
// code-bearing message never reaches an agent. Privilege is now a
// property of the paired user, not the chat: upsert the user, and if
// this instance has no owner yet, promote them to owner.
const existing = getMessagingGroupByPlatform('telegram', platformId);
if (existing) {
updateMessagingGroup(existing.id, {
is_group: consumed.consumed!.isGroup ? 1 : 0,
});
} else {
createMessagingGroup({
id: `mg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
channel_type: 'telegram',
platform_id: platformId,
name: consumed.consumed!.name,
is_group: consumed.consumed!.isGroup ? 1 : 0,
// Same context-appropriate default as router auto-create, so a
// paired chat behaves like any other telegram messaging group.
unknown_sender_policy: (consumed.consumed!.isGroup ? TELEGRAM_DEFAULTS.group : TELEGRAM_DEFAULTS.dm)
.unknownSenderPolicy,
created_at: new Date().toISOString(),
});
}
const pairedUserId = `telegram:${consumed.consumed!.adminUserId}`;
upsertUser({
id: pairedUserId,
kind: 'telegram',
display_name: null,
created_at: new Date().toISOString(),
});
let promotedToOwner = false;
if (!hasAnyOwner()) {
grantRole({
user_id: pairedUserId,
role: 'owner',
agent_group_id: null,
granted_by: null,
granted_at: new Date().toISOString(),
});
promotedToOwner = true;
}
log.info('Telegram pairing accepted — chat registered', {
platformId,
pairedUser: pairedUserId,
promotedToOwner,
intent: consumed.intent,
});
await sendPairingConfirmation(token, platformId);
} catch (err) {
log.error('Telegram pairing interceptor error', { err });
// Fail open: pass through so a pairing bug doesn't break normal traffic.
hostOnInbound(platformId, threadId, message);
}
};
}
registerChannelAdapter('telegram', {
factory: () => {
const env = readEnvFile(['TELEGRAM_BOT_TOKEN']);
if (!env.TELEGRAM_BOT_TOKEN) return null;
const token = env.TELEGRAM_BOT_TOKEN;
const telegramAdapter = createTelegramAdapter({
botToken: token,
mode: 'polling',
});
const bridge = createChatSdkBridge({
adapter: telegramAdapter,
concurrency: 'concurrent',
extractReplyContext,
supportsThreads: false,
defaults: TELEGRAM_DEFAULTS,
transformOutboundText: sanitizeTelegramLegacyMarkdown,
maxTextLength: 4000,
});
const botUsernamePromise = fetchBotUsername(token);
const wrapped: ChannelAdapter = {
...bridge,
resolveChannelName: async (platformId: string) => {
const chatId = platformId.split(':').slice(1).join(':');
if (!chatId) return null;
try {
const res = await fetch(`https://api.telegram.org/bot${token}/getChat`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ chat_id: chatId }),
});
const data = (await res.json()) as { ok?: boolean; result?: { title?: string } };
return data.ok ? (data.result?.title ?? null) : null;
} catch {
return null;
}
},
async setup(hostConfig: ChannelSetup) {
const intercepted: ChannelSetup = {
...hostConfig,
onInbound: createPairingInterceptor(botUsernamePromise, hostConfig.onInbound, token),
};
return withRetry(() => bridge.setup(intercepted), 'bridge.setup');
},
};
return wrapped;
},
defaults: TELEGRAM_DEFAULTS,
});

Some files were not shown because too many files have changed in this diff Show More