Skip to content

add .hardno/ignore to skip .pi/ from reviews - #1

Merged
royosherove merged 1 commit into
mainfrom
add-hardno-ignore
May 4, 2026
Merged

add .hardno/ignore to skip .pi/ from reviews#1
royosherove merged 1 commit into
mainfrom
add-hardno-ignore

Conversation

@royosherove

Copy link
Copy Markdown
Member

Prevents pi-hard-no from reviewing changes to .pi/ settings files.

@royosherove
royosherove merged commit b0add99 into main May 4, 2026
royosherove added a commit that referenced this pull request May 7, 2026
* perf: eliminate redundant file reads + flush model support

Fix #1: Cache MemorySnapshot and fileSet in PreparedTurn. In finalizeMemoryForTurn,
skip the disk re-read entirely when no tools were used during the turn (turnUsedTools=false).
When tools ran, reuse the cached fileSet (avoids re-resolving paths) but still re-read
file contents to detect modifications. handleStreaming() now returns { usedTools } flag.

Flush model: Add config.compact.flushModel option and AgentAdapter.promptWithModel()
method. When configured, memory flush turns use a cheaper/faster model (e.g. Sonnet)
instead of the expensive conversation model. Pi adapter implements via session.setModel()
with automatic restore after flush.

All 217 tests pass, no new type errors.

* perf: default flush model to Sonnet (faster)

Flush turns are structured tasks (read context → write markdown) that don't
need frontier reasoning. Sonnet responds 3-5x faster than Opus for these turns.

Default: amazon-bedrock/us.anthropic.claude-sonnet-4-6-20250514-v1:0
Set flushModel: null in config to fall back to conversation model.

* fix: address codex review — no model persistence, no complement I/O

P2: Replace session.setModel() with in-memory agent.state.model swap.
Avoids persisting flush model to settings.json or session log. Crash-safe:
if process dies mid-flush, settings remain on user's configured model.

P3: Revert complement/unknown mode pre-read. finalizeMemoryForTurn skips
digest checks for complement mode, so reading files was wasted I/O.

* fix: restore model unconditionally, track only file-modifying tools

- Always restore agentState.model in finally (even if undefined)
- Only set turnUsedTools=true for write/edit/bash/multi_edit tools
  (read/grep/ls/find are read-only — no need to re-hash memory files)

* fix: invert tool allowlist — use read-only set instead of write set

Unknown/extension tools now trigger memory re-read (safe default).
Only known read-only tools (read, grep, find, ls, glob) skip re-read.
Addresses codex review: custom extension tools that modify memory files
would have been missed by the previous write-tool allowlist.

* feat: add timing info to compact messages + persistent timing log

- Telegram messages now show flush/compact/total time + model used
- Timing persisted to ~/.roundhouse/logs/compact-timing.jsonl (JSONL)
- Both manual /compact and auto-compact show timing

* refactor: extract READ_ONLY_TOOLS + CompactResult to types, add tests

- READ_ONLY_TOOLS: shared constant in memory/types.ts (not buried in handleStreaming)
- CompactTiming + CompactResult: proper types in types.ts (SRP)
- lifecycle.ts imports CompactResult instead of inline type
- gateway.ts imports READ_ONLY_TOOLS + CompactResult from types
- Tests: READ_ONLY_TOOLS membership, CompactResult with/without timing

* feat: use Haiku for compact step too (compactWithModel)

Before: flush used Haiku (238s) but compact restored to Opus (152s) = 390s total
After: both flush AND compact use Haiku via new compactWithModel adapter method

- Added compactWithModel?(threadId, modelId) to AgentAdapter interface
- Implemented in pi.ts: in-memory model swap around session.compact()
- lifecycle.ts: prefers compactWithModel when available, falls back to compact()

* feat: live progress updates during compact (edits Telegram message in-place)

- New telegram-progress.ts: createProgressMessage() → editable message handle
- Compact shows: 'Flushing memory...' → 'Compacting... (flush took Xs)' → final result
- Single message edited in-place (no spam of multiple messages)
- Works for both manual /compact and auto-compact
- Falls back gracefully for non-Telegram adapters (no-op updates)

---------

Co-authored-by: Loki FastStart <loki@faststart.internal>
royosherove added a commit that referenced this pull request May 14, 2026
…31) (#133)

* refactor(session-repair): beautify per code review (7 findings)

Addresses all 7 findings from the post-v0.5.30 maintainability review.
Pure refactor \u2014 zero behavior change. 536 tests passing (+2 new for the
divergent-change fix).

#1 + #7 \u2014 DRY: extract matchesErrorPatterns + fix divergent change
  isContextOverflowError and isToolPairingError shared ~80% of structure
  (top-level .message, cause-chain walk, stringify-gate, circular try/catch)
  but with subtle drift: only one walked the cause chain. Extracted
  matchesErrorPatterns(err, patterns, { stringifyGate }) shared helper.
  Both classifiers now ~7 lines and BOTH walk cause chains. Added 2 tests
  proving isToolPairingError now classifies wrapped/Bedrock-nested
  tool-pairing errors (regression guard for the divergent-change fix).
  Also extracted looksLikeValidationError() shared gate so the two
  classifiers don't repeat the 4xx/ValidationException check.

#2 \u2014 long method: extracted buildTrimmedEntries(entries, cutIdx) from
  softResetSessionFile. The orchestration is now linear: validate \u2192 cut
  \u2192 build \u2192 repair \u2192 write \u2192 report. Each step at the same abstraction
  level.

#3 \u2014 long catch with nested try: extracted attemptSoftResetRecovery()
  in lifecycle.ts. flushMemoryThenCompact catch block went from ~60
  lines with nested try/catch to ~25 lines, linear: classify \u2192 recover
  \u2192 log \u2192 persist.

#4 \u2014 magic number: MAX_CAUSE_CHAIN_DEPTH = 5 named constant.

#5 \u2014 file >400 lines: split src/agents/shared/session-repair.ts (574
  lines, two domains) into:
    - session-repair.ts (81 lines) \u2014 public surface, tool-pair repair,
      re-exports for backward compat
    - session-soft-reset.ts (120 lines) \u2014 softResetSessionFile + types
    - error-classifiers.ts (75 lines) \u2014 matchesErrorPatterns +
      isContextOverflowError + isToolPairingError + MAX_CAUSE_CHAIN_DEPTH
    - session-repair-internal.ts (239 lines) \u2014 shared filesystem +
      in-memory repair primitives
  All public exports preserved via re-exports (Feathers seam pattern).
  Existing callers compile unchanged.

#6 \u2014 naming: introduced RepairResult { entries, report } named type for
  the previously-anonymous repairEntriesInMemory return shape. Better
  IDE hover/autocomplete; no inline destructuring noise at call sites.

Verification:
  npm test \u2014 536 passing (was 534, +2 for cause-chain regression tests)
  wc -l src/agents/shared/session-repair.ts \u2014 81 (was 574)
  All public API surfaces preserved.

* chore: bump to v0.5.31 + CHANGELOG

---------

Co-authored-by: Roy Osherove <575051+royosherove@users.noreply.github.com>
royosherove added a commit that referenced this pull request May 29, 2026
…streaming (#151)

* chore(slack-adapter): bump chat SDK to 4.29 + add slack-plan

Phase 0 of slack-plan.md: bump chat, @chat-adapter/telegram,
@chat-adapter/state-memory from ^4.26.0 to ^4.29.0. All 591 tests pass.
Diffed telegram d.ts 4.26 vs 4.29 — only changes that touch us are
private→protected (no subclassing) and persistMessageHistory→
persistThreadHistory rename (we don't reference). video_note attachment
typing is additive.

Also adds .gitignore entry for .claude/ (cron lock, transcripts) and
slack-plan.md (4 review iterations, verdict ship-as-is).

* feat(slack-adapter): phase 1 — multi-transport refactor

Lays the groundwork for Slack to land alongside Telegram in a single
gateway. The gateway code path stays uniform (one this.transport.*),
but underneath it now routes across delegates via a new
CompositeTransportAdapter.

Type widening:
- GatewayConfig.allowedUserIds and notifyChatIds: number[] →
  (string | number)[]. Slack ids are strings; telegram are numbers.
- ChatThread.post: widened to string | { markdown } | { card, fallbackText? }.
- TransportAdapter.notify(chatIds): (string | number)[].
- TransportAdapter.createThread(chatId): widened.
- TransportAdapter.enrichPrompt(thread, text): added thread arg.
- TransportAdapter.registerCommands(): no token arg; adapter self-sources.
- isAllowed: dual lookup (numeric path keeps telegram match working,
  raw-string path matches slack `Uxxx`).

New TransportAdapter methods:
- ownsChatId(id) — pure shape check, used to partition notify().
- encodeParentThreadId(chatId) — sub-agent / cron parent thread id.
- formatNotifySession(chatId) — startup notification "Session: …" label.
- shouldIgnoreMessage?(text, message, thread) — per-transport pre-handler
  filter. Telegram /start filter moved here from the gateway.
- stream(thread, iter, signal?) — per-transport streaming dispatch.

CompositeTransportAdapter:
- Routes per-thread methods by ownsThread(thread).
- Partitions notify() chat ids by ownsChatId() and fans out.
- handlePairing returns the first non-null result, tagged with the
  delegate name so the gateway can mark pairingComplete per-transport.
- Exposes ownerOf(thread) and ownerOfChatId(id) so the gateway can do
  per-transport gating without leaking transport semantics.

Per-transport pairingComplete:
- Was: single boolean. Stuck at true after the first transport paired,
  silently blocking the second.
- Now: Map<string, boolean> keyed by transport name. Slack pairing can
  proceed even after telegram pairing completed.

Number()-coercion sweep (regressions Slack would have hit silently):
- gateway.ts:113 — preserve string ids in handlePendingPairing.
- gateway.ts:329-333 — cron notifyFn signature widened.
- gateway.ts:352 — drop Number() cast on subagent chatId.
- gateway.ts:978-1001 — group/main label moved into transport.
  formatNotifySession.
- gateway.ts:1017-1021 — fireBootTurn partitions by transport, fires
  one boot turn per transport that owns at least one configured chatId.
- subagent-command.ts:30 — detect transport from chatId shape; encode
  parentThreadId via the matching transport (was hardcoded `telegram:`).
- ipc/handler.ts:17-30 — req.session sentinel check now uses
  transport.ownsChatId() instead of `/^-?\d+$/` regex; slack
  Cxxx/Dxxx/Gxxx/Uxxx sessions now route to a single target instead of
  falling through to "send to all".

Telegram adapter:
- registerCommands self-sources TELEGRAM_BOT_TOKEN.
- ownsChatId / encodeParentThreadId / formatNotifySession implementations.
- shouldIgnoreMessage swallows /start <nonce> handshake.
- createThread.post widened to ChatThreadPost; card path falls back to
  fallbackText until Phase 2 unifies through SDK postMessage.
- handlePairing decorates result with `transport: "telegram"`.

New chat-adapters.ts factory registry: lazy imports per platform; throws
loudly on a configured-but-uninstalled adapter so typos at config time
fail at startup, not silently.

Tests: 591 → 614 (all passing). New:
- composite-transport.test.ts (10 cases) — routing, partition, race
  walkthrough, action-id thread routing.
- ipc-handler-partition.test.ts (6 cases) — slack vs telegram session
  routing.
- unit.test.ts — 5 new cases for heterogeneous-allowlist isAllowed.

* fix(slack-adapter): phase 1 review fixes

Critical: gateway.ts:699 was calling enrichPrompt(text) with the
post-Phase-1 contract (thread, text). With one positional arg, JS bound
thread = string and text = undefined; every real agent turn would
silently overwrite the user's prompt with `undefined`. Tests didn't
catch it because prepareAgentMessage was never exercised with a fake
transport.

Fixes:
- gateway.ts:699 — pass `thread` to enrichPrompt(thread, text).
- util.ts isAllowed — userId can arrive as number from some platforms;
  widened type to `string | number` and normalized via String(). Old
  parseInt-based dual lookup missed the userId-as-number case.
- commands.ts:25, gateway.ts:757, gateway.ts:795, setup/types.ts:17 —
  residual `number[]` annotations widened to `(string | number)[]`.

New tests:
- prepare-agent-message.test.ts — pins enrichPrompt(thread, text) arity
  so this regression can never recur silently.
- unit.test.ts — case for `author.userId` as raw number against both
  numeric and string allowlist entries.

* feat(slack-adapter): phase 2 — Slack TransportAdapter (socket mode)

Implements Slack as a first-class TransportAdapter alongside Telegram in
a single gateway. v1: single workspace, socket mode only.

New module src/transports/slack/:
- slack-adapter.ts (TransportAdapter impl with attach(slackSdk) lifecycle)
- format.ts (isSlackChatId + Slack 12k markdown_text limit)
- pairing.ts (slack-pairing.json + matchPendingPairing — message.im AND
  assistant_thread_started paths)
- notify.ts (REST-only chat.postMessage helper for non-gateway callers;
  defaults match SDK to keep notify and gateway-emitted messages
  consistent)
- progress.ts (chat.postMessage + chat.update for editable progress)
- streaming.ts (post-then-edit fallback with throttled-overflow,
  abort-signal honoring, init-fail backoff with hard cap and final
  flush — closes the v3 review polish requirements)
- manifest.yaml (Slack app manifest for setup CLI to print)

Multi-transport completeness:
- richMenuToCard helper (transports/rich-helpers.ts) maps RichMenu to
  the Chat SDK's transport-agnostic CardElement; Slack's cardToBlockKit
  and Telegram's extractCard render to platform-native shapes inside
  the SDK. No per-transport Block Kit converter needed.
- stripMarkdownToPlain produces fallbackText for clients that can't
  render cards (Slack mobile previews etc.).
- Gateway constructor now wires SlackAdapter as a delegate when
  config.chat.adapters.slack is configured (Phase 1's eager throw
  removed).
- After chat.initialize(), gateway:
   • attaches the Chat SDK Slack adapter to our SlackAdapter delegate.
   • eagerly calls slackSdk.webClient.auth.test() to populate botUserId
     before subscriptions activate (closes the bot self-loop race
     window — Phase 2 risk #1).
   • registers bot.onAssistantThreadStarted to drive first-DM pairing
     before the user types anything (chicken-and-egg gap from §2.4).
- gateway/streaming.ts now dispatches via transport.stream(thread, iter,
  signal) — Telegram impl wraps handleTelegramHtmlStream; Slack impl
  wraps handleSlackStream. Removes the hardcoded isTelegramThread
  branch.

Type plumbing:
- Replaced flawed `import { Text } from "chat"` (mdast type, not the
  JSX factory) with a tiny local TextElement constructor in
  rich-helpers.ts so we don't pull jsx-runtime into a non-JSX file.
- Section/Actions take direct array per chat@4.29.0 jsx-runtime.

@chat-adapter/slack@^4.29.0 added to deps.

Tests: 617 → 651 (all passing). New:
- slack-adapter.test.ts (7 cases) — TransportAdapter contract, postRich
  uses {card, fallbackText} not {blocks}, notify routes via webClient.
- slack-format.test.ts (16 cases) — id shape checks, richMenuToCard
  output structure (no raw Block Kit), button chunking at 5,
  stripMarkdownToPlain.
- slack-pairing.test.ts (6 cases) — matchPendingPairing dual lookup
  (userName for message.im, userId for assistant_thread_started).
- slack-streaming.test.ts (5 cases) — initial post + edits, retry-with-
  backoff, abort honoring, empty-stream noop, non-slack-thread guard.

* fix(slack-adapter): phase 2 review fixes

Critical: createSlackAdapter only env-falls-back SLACK_BOT_TOKEN /
SLACK_APP_TOKEN / SLACK_SIGNING_SECRET when called with NO config
(zeroConfig = !config). Phase 2 passed `{ mode: "socket" }`, so
zeroConfig === false and botToken stayed undefined; every webClient
call would throw `AuthenticationError: No bot token available`.
Verified against @chat-adapter/slack@4.29.0 dist/index.js:4233-4243.
Tests didn't catch it because they all mock the SDK.

Fixes:
- chat-adapters.ts — explicitly forward env vars to createSlackAdapter
  (botToken, appToken, signingSecret) so they're populated regardless
  of whether other config keys are present.
- streaming.ts — gate INIT_FAIL_BACKOFF_MS on `initFailures > 0` so
  successful re-inits after handleOverflow aren't silently skipped
  when the prior send-initial happened within the backoff window.
- streaming.ts — handle overflow on the FIRST chunk too: if a single
  chunk pushes the uncommitted buffer past 12K before we've sent
  anything, slice + post directly via chat.postMessage. Without this,
  Slack would reject the initial post.
- types.ts — IncomingMessage.chatId widened to string|number (was
  number-only); SlackAdapter.handlePairing and gateway's
  assistant_thread_started synthesizer both pass strings.
- gateway.ts — drop the redundant explicit auth.test() call. The SDK's
  initialize() already calls it (verified against index.js:868-885)
  and populates botUserId before subscriptions activate.

New tests:
- slack-streaming.test.ts — overflow path: a 13K chunk produces ≥2
  posts with each markdown_text ≤ 12K, combined back to original
  length. Caught the bug above during implementation.
- transport-stream-dispatch.test.ts — pins the gateway/streaming.ts
  refactor: transport.stream(thread, iter, signal) is called when a
  transport is provided; falls back to thread.handleStream otherwise.

* feat(slack-adapter): phase 3 — roundhouse setup --slack

Adds an interactive + non-interactive setup flow for Slack alongside the
existing --telegram flow. Mirrors the telegram structure where the
underlying step is platform-agnostic; everything Slack-specific lives in
new files.

CLI:
- args.ts: new flags --slack, --slack-bot-token, --slack-app-token,
  --slack-signing-secret. Env fallback to SLACK_BOT_TOKEN / SLACK_APP_TOKEN
  / SLACK_SIGNING_SECRET. --slack and --telegram are mutually exclusive
  (run setup twice if both are wanted). Tokens in argv blocked in
  --non-interactive mode (avoid argv leakage in process listings).
  xoxb-/xapp- prefix validation at parse time so paste errors fail fast.
- setup.ts: orchestrator routes --slack to the Slack flows.
- setup/slack.ts: validateSlackBotToken (auth.test → SlackBotInfo),
  validateSlackAppTokenShape, redactSlackToken (preserves prefix + last
  4 chars so users can identify which token is broken), readBundledManifest.
- setup/slack-flows.ts: runInteractiveSlackSetup +
  runNonInteractiveSlackSetup. Doesn't reuse stepStoreSecrets /
  stepConfigure from the telegram path because both encode telegram-
  specific secret names and adapter defaults; uses focused
  stepWriteSlackEnv / stepWriteSlackConfig / stepWriteSlackPairing
  helpers that preserve any existing telegram config so multi-transport
  installs coexist.

Pairing:
- Interactive flow prints the bundled Slack app manifest inline AND
  saves a copy to /tmp/roundhouse-slack-manifest.yaml for paste
  convenience.
- Writes ~/.roundhouse/slack-pairing.json with status="pending" before
  starting the service. The gateway completes pairing on the first
  message.im or assistant_thread_started event from an allowed user.
- Final output explicitly tells the user to OPEN A NEW DM with the
  bot — the chicken-and-egg gap from slack-plan.md §2.4 (Slack only
  fires message.im for existing DM channels).

Tests: 654 → 665 (all passing). New setup-slack.test.ts covers:
- arg parsing (xoxb/xapp prefix gates, env fallback, mutually-exclusive
  --telegram/--slack, argv-leakage rejection in --non-interactive).
- redactSlackToken format.
- validateSlackAppTokenShape.
- readBundledManifest + privacy check (no users:read.email scope).

* fix(slack-adapter): phase 3 review fixes

Two P0 regressions vs telegram setup parity, plus three P1 fixes.

P0a — agent.configure() was skipped in slack flow.
Telegram's stepConfigure invokes agent.configure({ provider, model,
cwd, ... }) so pi writes ~/.pi/agent/settings.json with the right
provider/model. The slack flow's stepWriteSlackConfig wasn't doing
this, so a fresh `roundhouse setup --slack` install left the agent
mis-configured. Fix: stepWriteSlackConfig now takes the AgentDefinition,
calls agent.configure(...), and merges agent.configDefaults into the
gateway config's `agent` block (matches telegram step:416).

P0b — --with-psst was silently broken.
The slack flow ran `stepInstallPackages` (which installs the psst
runtime when --with-psst is set) but never stored secrets in psst —
they always went to .env regardless of the flag. Fix: new
`stepStoreSlackSecrets` mirrors telegram's stepStoreSecrets.
stepWriteSlackEnv now writes only non-secret fields when --with-psst
is on, matching telegram's split.

P1a — env values weren't envQuote'd.
Raw "${value}" interpolation would mis-parse if the bot's display
name (or any secret) contained `"`, `$`, backtick, or `\`. Slack lets
workspaces pick arbitrary bot display names so this is a real
exposure for systemd's EnvironmentFile shell expansion. Use envQuote()
like the telegram step does.

P1b — --slack-signing-secret wasn't in the argv-leakage gate.
Even though signing secret is unused for socket-mode v1, passing it
via --non-interactive would leak in `ps aux`. Added to the rejection
list alongside --slack-bot-token and --slack-app-token.

P1c — pairing hint printed $USER instead of the Slack username.
The user just typed their Slack handle into the wizard; we now show
opts.users in the "first message from … will complete pairing" line.

Cleanup:
- Dropped dead `unquoteEnvValue` re-export from slack-flows.ts.
- Stripped a dangling `cfgFileExists` import.

Tests:
- New: --slack-signing-secret in --non-interactive is rejected.
- 666 tests pass (was 665).

Still deferred to a follow-up (P2/P3 from review):
- Round-trip test for stepWriteSlackEnv with special-char botName.
- --slack --dry-run output (currently shows telegram-only message).
- printSetupHelp() doesn't list --slack flags.
- Manifest path resolution for src/dist/ build layout.

* feat(slack-adapter): phase 4 — additional test coverage + setup polish

Most slack tests landed alongside their feature in Phases 1-3 (composite,
ipc, slack-adapter, slack-format, slack-pairing, slack-streaming,
setup-slack, transport-stream-dispatch, prepare-agent-message,
isAllowed widening). Phase 4 closes the remaining gaps the iter-3
review flagged:

Tests (new):
- cron-notify-partition.test.ts — exercises the lambda the gateway
  builds for the cron scheduler (gateway.ts:349) against a real
  composite. Verifies a heterogeneous (string | number)[] partitions
  to telegram and slack delegates without dropping ids.
- setup-slack-validate-token.test.ts — mocked-fetch tests for
  validateSlackBotToken. Bar: never leak the raw bot token in any
  error message; parse user_id/team_id on success; fail loudly on
  HTTP errors and Slack-side ok:false.

Setup polish (P2/P3 items deferred from Phase 3 review):
- printDryRun: now branches on opts.slack so --slack --dry-run shows
  Slack-aware preview (validate xoxb+xapp, write slack-pairing.json,
  save manifest to /tmp, configure slack adapter) instead of the
  telegram-only message.
- printSetupHelp: now lists --slack, --slack-bot-token,
  --slack-app-token, --slack-signing-secret, env-var preferences,
  and notes mutual exclusion. Telegram-only flags (--qr / --bot-token
  / --notify-chat) annotated.
- readBundledManifest: probes two candidate paths so it works for
  both tsx (src/cli/setup/slack.ts) and a future src/dist/ build
  layout. Throws with both probed paths listed when neither exists.

Tests: 666 → 673 (all passing).

* fix(slack-adapter): phase 4 review fixes

Two items the iter-4 review flagged:

1. validateSlackBotToken had no AbortSignal.timeout on its fetch — a
   hung Slack endpoint would block setup forever. Added a 15s ceiling
   matching the gateway's notify path.

2. New gateway-multi-transport.test.ts: constructs a Gateway with BOTH
   chat.adapters.telegram AND chat.adapters.slack configured (the case
   no existing test covered), then walks the composite via the same
   accessors gateway.start() uses. Closes the slim untested seam in
   buildTransportDelegates + the slack post-initialize wiring around
   gateway.ts:348-378.

   Cases:
   - constructor doesn't crash with both adapters configured.
   - composite owns both delegates (names: ["slack", "telegram"]).
   - ownsChatId routes telegram numeric vs slack Cxxx/Dxxx/Uxxx; rejects garbage.
   - ownsThread routes by platform prefix, including the
     adapter.telegramFetch boundary check that real telegram threads carry.
   - notify partitions a heterogeneous chat-id list to both delegates;
     unrecognized ids dropped, not silently broadcast.
   - formatNotifySession routes labels through the correct transport.

Tests: 673 → 678 (all passing).

* docs(slack-adapter): phase 5 — README, architecture, CLAUDE, CHANGELOG

Closes the slack-adapter rollout (Phases 0-5). All 678 tests pass on
the slack-adapter branch.

README:
- New "Slack quick start" section: app manifest, token generation, env
  vs interactive setup, pairing flow with the explicit "open a NEW DM"
  instruction (chicken-and-egg gap from slack-plan.md §2.4), and a
  feature support matrix (what's in v1, what's deferred).
- Config reference: documents `chat.adapters.slack`, widens
  `allowedUserIds`/`notifyChatIds` to mixed Telegram numeric + Slack
  string ids, calls out `SLACK_BOT_TOKEN`/`SLACK_APP_TOKEN` as the
  preferred secret-storage path.

architecture.md:
- New "Transport composition" section with an ASCII diagram of how
  CompositeTransportAdapter routes across delegates by
  ownsThread/ownsChatId. Tabulates per-method routing rules
  (postMessage/postRich/notify/handlePairing/etc.).

CLAUDE.md:
- Updated "Adding a new chat platform" instructions for the new
  composite + factory-registry layout.
- New "Multi-transport composition" subsection.
- New "Slack adapter nuances" subsection covering: thread-id format
  (slack:CHANNEL:THREAD_TS), AdapterPostableMessage shape (no `blocks`
  field — use `{ card }`), streaming-vs-cards constraint, bot
  self-loop filter timing, pairing chicken-and-egg via
  assistant_thread_started, per-transport boot turn partition, and the
  createSlackAdapter env-var fallback gotcha (zeroConfig = !config).
- New "Type widening for multi-transport" subsection — calls out the
  Number()-coercion sites caught in Phase 1 so future maintainers
  don't reintroduce them.

CHANGELOG: 0.6.0 release notes covering all of Phases 1-4. Version
bumped from 0.5.41 → 0.6.0 in package.json.

* fix(slack-adapter): phase 5 review fixes

Three concrete factual errors flagged by the iter-5 docs review, plus
a migration note for external adapter implementers.

README:
- "Adding a new chat platform" snippet was OUT OF DATE: showed the
  old `if (config.slack)` inline-import pattern from before Phase 1's
  factory registry. Replaced with the real three-step process (register
  in chatAdapterFactories → implement TransportAdapter →
  buildTransportDelegates entry).
- "Files" table: stale `src/gateway.ts` → `src/gateway/gateway.ts`.
  Stale test count `311 passing` → `678 passing`. Added
  src/transports/* entries that didn't exist when the table was
  written.
- Slack quick start step 2: promote the `/tmp` manifest path the setup
  CLI writes (npm-installed users don't have the source tree handy);
  cite the in-source path as a reference rather than the primary
  paste source.

CLAUDE.md:
- Stale `src/gateway.ts` and `src/agents/pi.ts` paths corrected to
  current locations.
- Added the missing nuance: SlackAdapter.attach(slackSdk) MUST be
  called after chat.initialize() (otherwise webClient calls throw
  AuthenticationError). The gateway does this for you in start();
  documenting the constraint so a future maintainer doesn't try to
  hoist the call.

architecture.md:
- Config-model diagram: `slack: { ... } # (future)` → real shape
  `slack: { mode: "socket" }`. Slack is no longer future-tense.
- Session-thread example: `slack:U12345` was wrong (Slack thread ids
  are `slack:CHANNEL:THREAD_TS`; U-prefixed ids are users, not
  channels). Corrected to `slack:D12345:`.

CHANGELOG:
- Added migration note at top of 0.6.0 section calling out the
  TransportAdapter interface changes for external adapter implementers.
  Internal users (telegram + slack only) need no action.

Tests: 678 passing.

* fix(slack-adapter): remove sensitive test tokens, use placeholders

* fix(slack-adapter): per-transport botUsername + pin SDK dependency

Blockers fixed:
1. Per-transport bot identity: Store optional botUsername override in adapter config (e.g., chat.adapters.slack.botUsername). Resolve at dispatch time via BotUsernameResolver, with fallback to global chat.botUsername. Prevents Slack setup from breaking Telegram command matching when bot names differ.

2. Pinned Slack SDK: Change @chat-adapter/slack from ^4.29.0 to =4.29.0 to prevent silent breaks from minor version bumps that change SDK internals (post API, thread status, typing handler).

Design: Codex-approved resolver pattern (SRP + backward compatible)
- BotUsernameResolver: pure resolver, no transport awareness, explicit fallback
- gateway.ts: resolve per-thread at dispatch time, pass to matchers
- test: updated dispatchInTurnCommand signature to use resolved botUsername

All 680 tests passing.

* fix(slack-adapter): pre-turn botUsername matching + type-safe overrides + test coverage

Fixes 3 medium regressions identified by Codex review:

1. Pre-turn commands lost @botName matching
   - Issue: /cancel@telegram_bot, /verbose@..., etc. broken in Telegram groups
   - Root: Pre-turn dispatch used empty string "", in-turn had per-thread resolution
   - Fix: Extract buildMatchers(botUsername) factory, use at dispatch time for both stages
   - Result: @botName matching works for pre-turn + in-turn (groups and DMs)

2. Adapter override extraction not type-safe
   - Issue: Non-string botUsername values crash when helpers.ts calls .toLowerCase()
   - Root: Constructor didn't validate override types
   - Fix: Only accept string overrides at extraction time, ignore non-strings
   - Result: Config errors don't cascade to runtime command matching crashes

3. Test coverage incomplete
   - Issue: Updated test still used old interface; didn't verify per-adapter resolution
   - Root: GatewayInternals signature stale, test bypassed resolver with hardcoded value
   - Fix: Update interface, delete unused matchers plumbing, add resolver unit tests
   - Tests: Slack override vs Telegram fallback, fallback behavior, no override case
   - Result: 3 new tests (683 total, +3 resolver tests)

All 683 tests passing. Pre-turn and in-turn now use shared matcher factory.

* fix(slack-adapter): preserve env keys + support mixed notify ID types

Fixes 2 Codex findings for multi-transport coexistence:

1. Preserve existing env keys when rebuilding .env (P1)
   - Issue: stepConfigure() rebuilds .env from scratch, dropping SLACK_BOT_TOKEN/SLACK_APP_TOKEN
   - Result: Rerunning setup --telegram after Slack setup silently disables Slack
   - Fix: Read existing .env upfront, preserve unrelated keys (SLACK_*, custom vars)
   - Now: Slack tokens persist even when Telegram setup reruns

2. Support both numeric and Slack string IDs in notifyChatIds (P2)
   - Issue: Setup merged notify IDs with map(Number).filter(!isNaN), dropping D.../C... IDs
   - Result: Slack notification targets removed on setup rerun
   - Fix: Preserve both types: numeric IDs (Telegram) + string IDs (Slack)
   - Type: (number | string)[] both in merge + config storage
   - Now: Slack D.../C... IDs persist in notifyChatIds

Multi-transport coexistence now survives rerunning setup (both adapters + env + notify IDs preserved).

Tests: 683/683 passing.

* fix(slack-adapter): route pairing dispatch to owning transport only

Fixes P2 Codex finding: cross-transport pairing side effects.

Issue: CompositeTransportAdapter.handlePairing() invoked all delegates for every message. Slack's handlePairing matches on username alone without checking thread ownership. Result: if Telegram sends a message with a username matching pending Slack pairing, Slack's pairing can fire with an invalid (Telegram) chat ID, corrupting notifyChatIds.

Fix: Early-return if delegate doesn't own the thread. Only Slack adapter processes Slack threads, only Telegram processes Telegram threads.

Pattern: Applies to pairing logic (ownerOf check required before dispatch). All other per-thread methods already have this guard.

Tests: 683 passing (pairing tests verify per-transport isolation).

* fix(adapters): add defensive ownsThread checks to handlePairing (all adapters)

Belt + suspenders: CompositeTransportAdapter already filters pairing dispatch to owning transport, but individual adapters should self-protect too.

Pattern: Consistent with defensive early-returns in other methods. Prevents accidental cross-transport dispatch if adapters are called directly (tests, future changes).

Applies to: Telegram + Slack (only adapters with handlePairing).

Tests: 683 passing.

* fix(pr#151): address Codex CLI review findings (2 issues)

Issue 1 — HIGH: Legacy roundhouse pair path breaks mixed-ID config

Problem: roundhouse pair coerced notifyChatIds through Number() and filtered NaN, dropping Slack D.../C... IDs. Violates backward compatibility for mixed Telegram+Slack deployments.

Fix: Apply same mixed-type coercion from setup/steps.ts to setup.ts
- Support (number | string)[] for notifyChatIds
- Preserve non-numeric Slack IDs during merge
- Only coerce to numeric when pushing new entries

Issue 2 — MEDIUM: Matcher refactoring incomplete

Problem: Pre-turn dispatch uses buildMatchers() factory, but dispatchInTurnCommand() still rebuilt matchers inline. Two implementations to sync = divergence risk.

Fix: Extract buildMatchers() as top-level function, use from both dispatch stages
- Eliminates code duplication
- Single source of truth for matcher logic
- Prevents accidental divergence (same regression can't happen again)

Files changed:
- src/cli/setup.ts: Mixed-type ID preservation (legacy pair path)
- src/gateway/gateway.ts: Extract buildMatchers() to top-level, use in both dispatch stages

Tests: 683 passing (no new changes, fixes only)

* fix(pr#151): cleanup dead matcher code + align setup.ts with setup/steps.ts

Remove dead matcher rebuild in handle() that was never consumed (was building
matchers then passing botUsername directly to dispatchInTurnCommand).

Also remove redundant Number() coercion in setup.ts to exactly match
setup/steps.ts pattern: pairTelegram() returns chatId: number, so
explicit coercion adds no value and makes pattern less clear.

Both changes align with Codex CLI review findings.

* fix(pr#151): remove stale _botUsername global (residual dead code)

After extracting BotUsernameResolver, command matching uses resolve() directly.
The module-global _botUsername cache is no longer read anywhere.

Removes:
- Let declaration at module scope
- Assignment during gateway init

Replaces assignment with comment: BotUsernameResolver handles per-transport
identity; no global fallback needed.

* fix(pr#151): restore buildMatchers + fix ownsThread tests

Two regressions from cleanup:
1. buildMatchers moved inside start() method → not accessible to
   dispatchInTurnCommand (private class method). Moved to module scope.
2. TelegramAdapter tests lacked adapter.telegramFetch for ownsThread
   check + ROUNDHOUSE_DIR module cache not cleared between tests.

Fixes:
- Extract buildMatchers to module scope (before class definition)
- Add adapter.telegramFetch stub to test fixtures
- Add vi.resetModules() to beforeEach to clear module cache

683/683 tests passing ✅

* fix(pr#151): preserve existing BOT_USERNAME in Slack setup

P1 issue: Slack setup was overwriting BOT_USERNAME in .env, breaking
Telegram group commands in mixed Telegram+Slack installs.

At runtime:
1. applyEnvOverrides reads .env + sets config.chat.botUsername
2. BotUsernameResolver falls back to this global for adapters without overrides
3. Telegram commands like /cancel@telegram_bot fail to match Slack bot name

Fix: Only set BOT_USERNAME if not already present (via !has() guard).
This preserves the Telegram value during Slack setup.

For true per-adapter bot names, we'd use TELEGRAM_BOT_USERNAME +
SLACK_BOT_USERNAME env keys (follow-up improvement).

683/683 tests passing ✅

* fix(pr#151): merge ALLOWED_USERS instead of replacing on Slack setup

P1 issue: Slack setup was replacing ALLOWED_USERS entirely, dropping
previously configured users when Slack is added to an existing install.

Effect: Env overrides take precedence at runtime, so the gateway's
effective allowlist becomes just the newly provided Slack usernames,
potentially locking out prior authorized users (e.g., Telegram users)
immediately after setup.

Fix: Parse existing ALLOWED_USERS (comma-separated), deduplicate with
new users via Set, then write merged value. Preserves all prior users.

683/683 tests passing ✅

* fix(pr#151): unquote ALLOWED_USERS before merge and split

P1 issue: parseEnvFile returns quoted values (e.g., "alice,bob"), but
merge logic was splitting the raw quoted string directly.

Effect: On second setup run with existing .env, entries become "alice/bob"
with quote characters, then re-quoted via envQuote → quotes become part
of usernames. At runtime quoted usernames don't match incoming authors,
silently de-authorizing previously allowed users after setup --slack.

Fix: Detect and strip surrounding quotes before splitting and merging.
Both non-psst + psst paths updated.

683/683 tests passing ✅

* fix(pr#151): derive per-transport boot session id

P2 issue: All boot turns used agentThreadId='main', causing cross-transport
session contamination in multi-transport setups.

Effect: In Telegram+Slack install, second boot turn reuses first transport's
agent session. No transport-specific session is seeded despite looping per
transport, so startup context is mixed.

Fix: Derive agentThreadId per transport via encodeParentThreadId(chatId).
Each transport now seeds its own isolated session.

Tests: Run needed (aborted earlier - retry below)

* fix(pr#151): preserve psst vault values + align Telegram boot session

P1 issue: psst path stored BOT_USERNAME + ALLOWED_USERS without preserving
existing vault values → users de-authorized after Slack setup in --with-psst mode.

Fix: Read existing vault values for both keys, merge ALLOWED_USERS, preserve
BOT_USERNAME if already set. Gracefully handle missing keys (new install).

P2 issue: Telegram encodeParentThreadId returned 'telegram:<chatId>:main'
but inbound routing uses 'group:<chatId>' for negative IDs. Boot turns seeded
different session than live group traffic → startup context not visible.

Fix: Align encodeParentThreadId to return group:<chatId> for negative IDs,
matching resolveAgentThreadId behavior. DM sessions stay as 'main'.

Total fixes now: 18 (14 original + 4 new)

---------

Co-authored-by: Roy Osherove <osherove@amazon.com>
Co-authored-by: Roy Osherove <575051+royosherove@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant