Skip to content

release: v0.12.0 — Slack, one agent session per thread - #210

Merged
firstintent merged 37 commits into
mainfrom
dev
Oct 4, 2026
Merged

firstintent merged 37 commits into
mainfrom
dev

Conversation

@firstintent

Copy link
Copy Markdown
Owner

Release v0.12.0 — Slack as an IM, one agent session per thread (36 commits since v0.11.2).

What's in it

  • Slack IM (local M0.18: phase prompt architecture — protocol/domain split (D方案) #19): Socket Mode provider (outbound WebSocket, no public URL). Every agent session lives in its own Slack thread: a top-level message starts a session, replies in its thread reach only it, all of its output (answers, progress, approvals, files, delegation follow-ups) lands there. Session focus is per thread; ownership and the current project are per channel; a session lives in one thread at a time.
  • Guided web setup (Settings → Access → Slack): one-click create-app link with the manifest prefilled (slash command named after the app, so several ccteam daemons can each have one), token validation, click-to-allow member id.
  • Buttons first: session controls (status · model · interrupt, weighted so interrupt is the small one and asks first on Slack), a main menu, dropdowns for long lists; on Slack commands start with ! (Slack swallows / and allows no app slash command in threads).
  • ACL red line revised (owner decision): the owner's named IM chats share session visibility; a session still pushes only to its one reply target. Tenants, guests and the web console are unchanged.
  • Gateway: clickable pickers wherever the provider renders buttons (a provider capability, no platform name in the gateway); /help is a short grouped list.
  • test(codex): deflake writer-lock holder test (v0.11.2 main CI) #209: a running turn's words reach IM and web as it runs; interim answers never end a turn.
  • build: async-trait 0.1.92 (rustc 1.99 clippy double_must_use).

Version lockstep

Workspace 0.11.2 → 0.12.0 (8 crate lines in Cargo.lock), @ccteam/ccteam-ui version + ccteam.engine + the four @ccteam/engine-* + lockfile + PACKAGE_VERSION/ENGINE_VERSION; embedded ccteam-ui.tgz repacked, two-path reproducible (6a42da48…).

Local gate (release commit c95017d)

fmt clean · clippy 0 warnings (rustc 1.99) · test-baseline 2472/0 (v0.11.2: 2399) · SPA 775/775 · plugin 289/289 · ccteam-web 468 passed — the MCP enroll/bearer tests pass with the vendor CLIs on PATH (15/15, 8/8); pty_ws_test ws_* ×3 need a real PTY (known env flake, local #17, path untouched).

Independent review: codex checker CONFIRMED the thread-per-session core after two fix rounds.

🤖 Generated with Claude Code

randd1024 and others added 30 commits September 25, 2026 06:14
… time

`record_vendor_usage_in` stamps `observed_at = Utc::now()`, and a credits
balance is bounded by observation + 7 days. The "past every window" check used
a fixed 2026-09-30, so from 2026-09-24 on the credits were still inside their
bound and the assertion went red on every clean run (CI included). The product
rule is unchanged; the test now measures "past" from the observation it made.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
docs-local/issues/#209. A claude session talked for ten minutes; `/status`
could quote it (`💬 …`) but the chat got nothing until the turn ended. #207
taught the pump to release held text on a steer or after one heartbeat, but
only codex ever handed it text mid-turn: claude stream-json, the ACP family
(grok/opencode/kimi/dsh) and pi all reported a turn's text ONCE, at its end,
so the release had nothing to release on six of seven harnesses.

Fixed at the adapter contract, so every harness and both front ends inherit it:

- Each public message is an `ItemCompleted{AgentMessage}` the moment it is
  complete. claude: every top-level text block as it arrives (probed on
  2.1.280: one complete block per `assistant` line), `result.result` only when
  the stream never showed it. ACP: deltas have no end marker, so a new tool
  call completes the message before it; the boundary reports only what is
  left; a context probe's reply stays quiet. pi: a message that hands over to a
  tool call is reported at `message_end` (it used to be overwritten by the next
  one and never reach anyone).
- The pump folds what it holds into ONE delivery (one chat message per release,
  not one per line) and releases it on the boundary, a steer, or one heartbeat
  — now also by a timer, so a line said just before a silent twenty-minute tool
  is not held until the tool returns. The due hold enters the same delivery
  path as an inert marker that skips every vendor-only step (liveness,
  heartbeat rows, accounting).
- A failed turn delivers what it said first as ordinary narration ahead of the
  failure row, instead of discarding it with the hold; only the failure row is
  marked failed and closes the turn.
- A folded boundary names its last message as the conclusion when the vendor
  names none (issue #196).
- Every boundary publishes exactly one status-bearing Answer. With no answer
  riding it, that is a content-less frame on the web stream; the IM status line
  goes to the IM chat only (it rendered on the web as a bubble holding nothing
  but a status line). A turn whose text went out mid-turn gets a durable
  closing row (empty `assistant`, status + usage), so a reload keeps its
  footer and the completed-turn count stays right.
- An interim answer only SEALS its progress card (`↳ N tools · M files`); `✅
  done` is the turn's end. An answer-less boundary closes its card too, so the
  next turn no longer edits the previous turn's card.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Usage (en/cn) and the DSH plugin guide describe the current delivery: every
harness hands the chat what a session says while its turn runs, folded to at
most one message a minute; a mid-turn message seals the progress card
(`↳ …`) and only the turn's end reads `✅ done`; the web keeps the turn running
until it ends; a failed turn still delivers what it said first.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A stream that opens no structured turn delivers its answer immediately, and
that answer is the only end the pump will ever see — sealing its card `↳`
left such a turn with no `✅ done` at all (im_progress_test caught it). Seal
only while a structured turn is known to continue; pin the card sequence of a
structured turn end to end (sealed card, the message, the boundary's done).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
#209)

Independent review of the first #209 cut:

- A new pump numbered rows by COUNTING rows with text, so a closing row
  (empty `assistant`) re-issued its id after every resume — and a turn id
  already in `notified_turns` silently swallowed the completion reusing it.
  Rows are now numbered after the highest `<sid>-N` on disk.
- "No status = still running" was an inference, and a wrong one: the terminal
  protocol's replies, slash-command replies, schedule failures and recovered
  answers carry no status and end their exchange all the same, and a web
  reader treating them as interim stayed busy forever. `GatewayEvent.interim`
  now says it positively — set only on an answer a structured turn delivered
  with that turn still running — and the SSE payload carries `interim: true`
  on exactly those.
- The turn watchdog read any answer as "the turn answered" and disarmed; an
  interim line no longer moves it.
- A parent was told only the last row of a turn that reached the ledger in
  pieces. The live boundary signal, the restart reconcile and the inline
  `agent{wait}` result now fold every row of the execution turn into its
  answer (issue #192), with the last row as the conclusion when the vendor
  marks none (issue #196).
- A whitespace-only message is never delivered; the CLI's own `<synthetic>`
  message (an API error, a usage limit) is left to the turn's `result`, which
  already reports it, instead of reaching the chat twice.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Both go out while the turn is still running — one says it went quiet, the
other waits on a human — so neither may end a reader's turn. Mark them
`interim` at the source (the silence watchdog, the HITL prompt and its SSE
re-seed on reconnect) instead of teaching each front end the exceptions.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The gateway now delivers what a session says DURING a long turn: zero or
more interim `answer` frames without a `status`, then exactly one
status-bearing boundary answer (final reply, or a status-only closing
frame with empty content). `progress{done:true}` now only seals one
progress card; each interim answer seals the current card and a new
epoch starts after it.

The SPA read "an answer arrived" or "a card finalized" as "the turn is
over", which under the new contract is wrong:

- SessionView derived busy from a count of `done` progress frames, so
  the first interim answer after tool activity dropped the Stop button
  and the streaming cursor while the turn kept running, and a boundary
  with no sealed card before it left busy stuck forever. The count also
  shrank once the 500-frame ring started dropping old frames.
- The team view ended a node's pulse on any answer or sealed card and
  reset the selected node's activity fold on any answer.
- mergeHistory dropped a late answer whose text matched ANY mirrored
  row, so a session repeating a short line mid-turn could lose it on a
  reconnect reseed.
- A status-only closing frame / history row rendered nothing, so its
  `turn N · ctx` footer was lost.

One predicate, `isTurnBoundary` (answer with a status), now answers
"is the turn over" everywhere. Busy is a send-time watermark into the
SSE buffer (`turnInFlight`), anchored on the frame's identity so ring
eviction cannot end or resurrect a turn. A status-only boundary draws
no bubble and footers the turn's last reply, live and in history,
never crossing a user row or an already-footered reply. The reseed
dedup matches the page tail by position instead of anywhere-equal text.
The global stream parser now keeps `status` so the team view can tell
the two apart.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The server now marks a mid-turn message positively with `interim: true`.
Reading "no status" as "still running" was wrong: terminal-protocol
replies, slash-command replies, failure notices and recovered answers
carry no status and end their exchange all the same, so those sessions
stayed busy forever. `isTurnBoundary` is now "an answer not marked
interim"; an approval prompt is excluded too, since the turn is blocked
on the human, not finished (before #209 an approval never ended busy
either). Both parsers read `interim`; the team view's parser also reads
approval options and parses `scheduled_changed` as itself instead of as
an answer (which pulsed the node for a window).

Review fixes:
- The history reseed matched mirrored late replies as a contiguous run,
  so a late reply turns.jsonl never stores (the watchdog heads-up, a
  slash reply, an IM-decorated reply) broke the run and doubled every
  reply after it; and a new line identical to the page's last reply was
  dropped. The page tail is now matched as an in-order subsequence of
  the late replies, anchored on the newest pre-request frame the page
  holds; when content cannot decide, a doubled line wins over a lost
  one. The IM context tag / status-line suffix is recognized.
- A send read `eventsRef`, which catches up only in an effect: a send
  between a commit and its effects anchored behind a boundary already
  on screen and ended busy at once. It now reads the rendered frames.
- A status-only closing frame skips standalone answers (no interim
  marker, no status) when picking the reply to footer, and a closing row
  that opens a history page now footers the earlier page's last reply on
  "load earlier".
- The team panel's recent turns skip closing rows (blank lines that also
  took one of the three slots).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The gateway now delivers what a session says during a long turn as interim
answers (kind "answer", no status) and closes every turn with exactly one
boundary answer carrying the TurnStatus object - possibly with empty
content, a status-only closing frame. The workbench treated every answer as
the end of the turn, so each interim line hid the working row and Stop,
bumped the badge, and re-read history, and the closing frame left an empty
row behind. The BFF also read `status` with a string accessor while the
wire carries an object, so the two kinds could not be told apart at all.

- contract: type `status` as TurnStatus and add `isTurnBoundary`, the one
  predicate both halves ask (status present, not a choice prompt).
- bff: parse the status object (null or absent = interim) through one
  shared answer parser; `turn_done` and the tree re-read fire only at the
  boundary, and a closed progress card (done) no longer re-reads the tree,
  since a long turn closes several cards.
- store: an interim answer settles into a row with the steps that finished
  before it and keeps the turn working (live, Stop, timer and the queued
  chip stay); the boundary ends it, and an empty boundary draws nothing -
  leftover live steps join the last settled row. A late step update lands
  on the row that holds the step, history drops blank assistant rows, and
  reconcile pairs each settled row with its own canonical row from the
  newest end so repeated lines keep their steps.
- chat: history and statusline are re-read at the boundary only.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Inferring "interim" from a missing status was wrong: terminal-protocol
replies, slash-command receipts (the composer's own /model switch),
scheduled-send notices and answers recovered after a restart all carry no
status and all end their turn. They left the chat "working" for good - no
turn_done, Stop could not clear it, and the statusline stopped refreshing
after /model. The gateway now marks mid-turn deliveries explicitly with
`interim: true`, so the BFF carries that flag and `isTurnBoundary` becomes
"an answer, not interim, not a choice prompt"; status is advisory again.

A review of the first pass found the store settling steps the wrong way:

- A done progress card completed every live step, and a timer seal lands
  mid-tool by design, so running tools were marked finished and moved into
  the interim row. A done card now only makes the card final: steps finish
  on their own completion events, and whatever still runs settles at the
  turn's end. Nor does a sealed card clear a pending choice.
- A late event for a settled step rewrote it in its row, so ACP's stable
  per-turn pulse id flipped a finished row back to a spinner for good. A
  settled step is final: only a completion may refresh it, and the lookup is
  scoped to the running turn because some vendors reuse tool ids per turn.
- An interim answer no longer clears "waiting" on a pending choice.
- Reconcile placed rows with no canonical counterpart by heuristic (onto
  the last canonical answer, or at the tail), so a tool-only turn's steps
  row gave its steps to the next turn's answer, and a file sent mid-turn
  without text vanished. Unmatched rows now keep their place next to the
  row they followed; provisional rows pair with canonical ones from the
  newest end, each claimed once. A status-only close leaves its leftover
  steps as their own row where the live block showed them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Second independent review of #209:

- An answer-less boundary never moved `visible_events`, so a turn whose every
  line went out mid-turn stayed armed in the stall watchdog and was reported
  silent — and STUCK to every reader — minutes after it had finished. The
  boundary disarms it now, whatever rides it.
- `chat_send_file` is called from inside a running turn; its event now says
  `interim` while the caller's turn is in flight, so a web reader no longer
  drops Stop when a file arrives mid-turn.
- `turn_answer_record` is the one definition of "the answer a turn id names"
  — for the notification, the restart reconcile, `agent{wait}` and now
  `agent_read{turn}`, so a completion's "read the rest" pointer returns the
  answer it counted. The turn is delimited by its own rows (pi and
  codex-exec reuse execution ids across restarts): walking back it stops at
  the previous turn's status row, a terminal outcome or another turn's text;
  walking forward the closing row lends the status and the vendor's
  conclusion the pieces went out without.
- The pump's per-turn text list restarts at `TurnStarted` and on a failure
  boundary, so neither text said outside a turn nor an unappendable failure
  leaks into the next turn's answer.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…209)

A thought/message chunk arriving with no turn buffer open is dropped from
every answer, but its liveness pulse still went out as `pending-live-*` — and
read downstream (the pump's progress fold, a web client) as a new turn
starting after the last one had ended. With nothing running there is nothing
for a pulse to keep alive.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…209)

closeTurn dropped the live turn and, with it, what the turn had settled,
so any activity arriving after the boundary took the "open a new live turn"
path and set the chat working until the next turn-ending answer. Two
sources do this by design: ACP's liveness pulse, which falls back to a
`pending-live-*` id once no turn buffer is open (and whose fold also emits
a fresh progress card), and a late completion for a step of the turn that
just ended (the boundary force-completes whatever was still running).

The chat now remembers the ended turn's step ids until a new turn is under
way. Between turns, an event for one of those steps refreshes its row on a
completion and is otherwise dropped; a liveness pulse, and the card a pulse
folds into, open nothing. A genuinely new turn still shows working: a send,
an interim answer or choice prompt, or tool activity with an unseen id
clears the guard.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
On Telegram every agent session shares one bot stream, so concurrent
sessions talk over each other. Slack gives each session its own thread;
this is the channel-neutral gateway half: a thread is the session's tab
(the IM twin of the web console's per-session tab), while ownership, the
ACL principal and the current project stay per conversation.

- ChatKey carries an optional thread. Owner/identity keys never do
  (canonical_owner drops it in every branch), so a session started in one
  thread is owned by, listed in and addressable from every thread of the
  conversation.
- FocusRoutes takes a FocusScope at construction (no Default):
  current_project is per conversation, current_session per thread. Every
  accessor and the routing.json load normalize through one key fn;
  threads_bound_to answers which threads a sid is the tab of. The spawn
  single-flight claim keys by the same thread slot.
- handle_message / handle_message_shared / inbound_may_spawn /
  has_current_session take the thread. The daemon passes the inbound
  thread_ts only when Channel::session_threads() is true (new trait
  method, default false; MockChannel::with_session_threads for tests).
- Every outbound event built from a session's reply_to or a chat key
  stamps that key's thread: answers, closing status receipts, progress,
  activity, reactions, pickers, choice prompts, stall warnings, recovered
  turns, scheduled-failure notices, HITL prompts (HitlPromptContext), and
  the MCP chat_send_file / interaction / permission prompts
  (reply_target_for now returns a ReplyTarget with thread_ts).
- An internal turn (a delegation completion notification) resets
  reply_to to an owner-derived target, and an owner has no thread: the
  answer read as unfocused and lost its IM leg. with_bound_thread puts
  the session's thread back (the current reply_to's thread while it is
  still bound to the sid, else the most recent bound thread), applied
  through owner_reply_target / owner_reply_target_for_meta at every
  owner-derived reset, plus /role and the thread-keeping
  resume_stopped_session{,_shared}_as cores.

Telegram, Lark and web never pass a thread, so their routing, focus and
delivery are unchanged.

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
rustc/clippy 1.99 reports `clippy::double_must_use` inside the
`#[async_trait]` expansion of 0.1.89 (34 errors in ccteam-harness), so
`make check` and CI's `clippy -D warnings` on the current stable fail
before reaching any workspace code. 0.1.92 no longer emits the doubled
attribute.

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
On a single-stream chat `/cd` adopts the project's existing session so
the switch never mints a duplicate beside the one being talked to. A
thread is one session's tab, so adopting there pulled a session that
already lives in another thread into this one. In a thread `/cd` now
keeps the thread's own session when it is in the target project and
otherwise frees the thread, so its next message starts a session in the
project — the same thing a new thread does.

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
Fold Slack into the current-capability description next to Telegram and
Lark: the Socket Mode app manifest, the credentials block, the member-id
allowlist that doubles as the owner roster, and how threads map to
sessions (top-level message = new session, `/ccteam` for gateway
commands, project per channel, focus per thread).

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
On Telegram several agent sessions share one bot stream and their
replies interleave. Slack has native threads, so the Slack provider
gives every session its own thread: it advertises the new
`Channel::session_threads()` contract and stamps every inbound
ChannelMessage with the thread it belongs to (top-level message -> its
own ts, reply -> parent thread_ts, `/ccteam` -> a fresh anchor message,
button click -> the clicked message's thread).

The old polling provider is replaced outright (Pre-v1, no migration):

- Inbound is Socket Mode (apps.connections.open + WSS, no public
  endpoint). Every envelope is ACKed before any slow work and handed
  to an ordered processor; refresh/warning disconnects reconnect at
  once, drops reconnect with capped backoff, link_disabled and auth
  failures stop the listener; re-deliveries are deduped.
- The allowlist (Slack user ids) is fail-closed like Lark. Rejected
  senders are always probed, but only get the one-time binding notice
  when they addressed the bot (DM, mention, slash command, click).
- Outbound posts a `markdown` block (+ option buttons), retrying once
  as plain text if Slack rejects the blocks; edits use chat.update,
  the ack is the 👀 reaction, files use the external-upload flow,
  inbound files are downloaded with the bot token and staged.
- SlackCreds become {bot_token, app_token, allowed_user_ids};
  onboarding validates both tokens; the daemon builds the channel with
  the rejected-sender probe and binds the allowlist as the operator
  roster. The dead signing-secret stub goes with the webhook design.

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
…PI (#19)

Slack needs the same validate-before-persist setup Lark has, from both
operator surfaces:

- `ccteam config` gains a Slack item (`run_config_set_slack_creds` +
  `_with_base` test seam): validate the bot token (auth.test) and the
  app-level token (apps.connections.open), merge into the credentials
  file without touching other platforms, nudge a live reload, and print
  the Slack app checklist (Socket Mode, /ccteam, interactivity, scopes,
  events) so a half-configured app is not a silent dead bot.
- `GET /api/v1/config/im` reports a masked `slack` block (token tails
  only, plus the non-secret member-id allowlist);
  `PUT /api/v1/config/im/slack` validates, saves and hot-applies like
  the Lark handler, admin-only behind the same gate.

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
The admin configures the global Slack app next to Telegram and Lark:
a SlackSection mirroring the Lark card (bot token, app-level token,
allowed Slack member ids; tokens never pre-filled or echoed, overwrite
needs the inline confirm, empty allowlist warns fail-closed) backed by
`saveSlack()` and the masked `slack` status in `getImConfig()`.

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
The reaction comments still listed Slack among the channels that keep the
trait's no-op `add_reaction`; Slack now adds and clears `eyes` like
Telegram. The documented manifest gains the App Home messages tab so the
bot can be DMed, matching the `ccteam config` checklist.

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
… left (#19)

`/use s1` (or `@s1`, or a nav button) from thread B bound B to s1 while
thread A stayed bound too, and s1's `reply_to` followed whichever thread
drove it last. Its later output — answers, progress, approvals,
delegation notifications — then went to B and the thread the human
knows the session by went quiet.

The focus table now keeps one thread per session per conversation:
binding a session to a thread unbinds it from the conversation's other
threads and returns them, and `Gateway::focus_session` (the one writer
of `current_session`) posts a notice into each thread it left. With that
invariant the owner-target re-attach no longer needs a "current thread"
preference. Single-stream chats are unaffected.

Found by the independent checker (s671).

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
`FocusRoutes::set` keeps a session in one thread of a conversation, but
`load_saved` restored every persisted thread key on its own, so a
`routing.json` naming a session in two threads brought both back after a
restart. The thread-scope load now applies the same invariant: the
session's most recent thread keeps it and the others are freed.

Found by the independent checker (s671).

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
…19)

`/projects` and `/sessions` on Slack came back as a plain text list: the
gateway decided "this chat can show buttons" with a hardcoded
`platform_of(channel) == "telegram"`, so a new provider with native
buttons fell through to text.

The capability now lives on the provider (`Channel::native_buttons`,
true for Telegram and Slack), and the daemon reports each live channel's
value to the gateway at startup and on every IM reload
(`Gateway::bind_channel_buttons`). The gateway no longer names a
platform; Lark, web and the mock keep the plain-text picker.

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
Every ccteam Slack app declared and matched a fixed `/ccteam`, so two
independent ccteam daemons with an app each in one workspace fought over
one command. The app manifest now has a single home
(`onboarding::slack_app_manifest`) that names the slash command after
the app (`cct2` → `/cct2`), and the provider no longer checks the name:
Socket Mode only ever delivers an app its own commands. The scopes and
events behind the manifest also feed the CLI checklist, which now ends
with a one-click create-app link.

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
…urself (#19)

IM setup lives in the web console, so the Slack card now walks the whole
way instead of asking for two tokens and a member id the user has to dig
up:

1. name the app and open Slack's create-app flow with the manifest
   filled in (`GET /config/im/slack/app-manifest` → `new_app=1&
   manifest_json=…`), or copy the manifest;
2. paste the bot and app-level tokens, each labelled with where Slack
   shows it; saving validates and connects;
3. DM the bot: the global bot's rejected senders show up
   (`GET /config/im/slack/user-id-candidates`) and one click allows a
   member (`PUT /config/im/slack/allowed-users`, which leaves the tokens
   alone).

All three routes are admin-only like the rest of the global IM config.

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
Replace the hand-copied manifest with the Settings → Access card's three
steps, explain that each independent ccteam needs its own app (Socket
Mode spreads one app's events across its connections) with its own slash
command, and note the free plan's 10-app limit.

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
Slack swallows every message that starts with `/` (Slackbot answers
"not a valid command"), and an app's own slash command cannot be invoked
inside a thread at all — so a session's thread had no way to send it
`/status`, `/model` or `/compact` short of a leading-space trick nobody
remembers. The Slack provider now reads `!word …` as `/word …` (a letter
must follow, so prose like `!!!` stays prose) and, from the command
names the daemon already registers with every channel, rewrites ccteam's
own `/name` references in what it sends to `!name`, so a hint like
`→ /status` names something a Slack user can type. Fenced code and paths
are left alone. The gateway stays channel-neutral.

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
On a channel that renders buttons a command's next step is now a row of
buttons under its reply instead of a `→ /status` footer to retype:

- a session's controls (📊 status · 🧠 model · ⏹ interrupt) after
  `/new`, `/use`, `/role`, `/interrupt`, `/status`;
- the session/project lists after `/stop`, `/rename`, `/cd`;
- a main menu (projects · sessions · status · + Claude · + Codex) for
  `/help` — so a bare Slack slash command opens it;
- a thread whose first message spawns its session opens with a header
  and that session's controls.

A tap carries `act:<what>`, which runs the whitelisted command it stands
for exactly as if typed in that chat (a session action in a thread
without a session is refused rather than spawning one). On Slack an
option list longer than six becomes a dropdown instead of a wall of
buttons. Channels without buttons keep the text footer unchanged.

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
`/help` dumped the full command reference — fourteen English lines with
every argument form — and on Slack it broke further: `/use <id|@ROLE>`
came out as a `mailto:` link (Slack reads `<a|b>` as its own link
syntax), and `/mcp` plus the closing `/command` line kept a `/` nobody
can type there.

Help is now five short lines grouped by intent (sessions · control ·
projects · schedule) plus one line on agent commands; on a button
channel the menu sits under it, so little of it needs typing. The `/use`
hint drops the `<a|b>` form, and the Slack provider also writes the
agent commands a reply names by example (`/model`, `/compact`, …) with
`!`.

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
…ne (#19)

The session controls gave interrupt the same weight as status and model,
and on Telegram each button took a full-width row of its own, so the one
button that stops work was as easy to hit as the ones people want.

Options now carry a channel-neutral `OptionWeight` (`Normal` by default
and off the wire; `Primary`; `Minor`), and each provider maps it to what
its buttons can do. Telegram sizes a button only by how many share its
row, so weighted options lay out as two `Primary` per row (widest) and
the rest three per row with `Minor` last (narrowest); plain pickers keep
one per row. Slack cannot size a button, so `Primary` gets the
highlighted style and `Minor` asks for confirmation before it acts.

A session's controls are 📊 status · 🧠 model (primary), 🧵 sessions ·
📁 projects, ⏹ interrupt (minor); the menu and the list steps lead with
projects/sessions.

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
A Slack list longer than six options became a collapsed dropdown, so a
session list showed only its text rows — sid and model, no titles —
while Telegram shows each session's title on its button. A plain picker
now renders one button per row like Telegram (a dropdown only past 20),
and labels drop the invisible U+2800 padding the gateway adds to
left-align Telegram's buttons. Weighted controls stay one styled row.

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
…s to one (#19)

Owner decision 2026-10-05: any of the owner's IM chats should list every
session, but a session must push only where the owner explicitly
switched to it — never a push storm.

Visibility and delivery were already separate mechanisms; only the
first one widens. `identity::operator_im_pool_sees`: an IM chat that a
global bot's allowlist NAMES as the operator (never the open-mode
fallback, where anyone counts as the operator) sees every IM-owned
session, whichever of the owner's chats created it. Web-console pools
(`user:*`) keep their rule, so tenants stay private and the web console
still does not list IM sessions; a guest sees nothing.

Delivery stays single-target: a session answers its one `reply_to`, the
chat that last drove it or switched to it. A follow-up nobody typed (a
delegation completion) used to fall back to the creating chat; it now
stays with the chat that switched to the session while that chat still
has it in focus, so taking a Telegram-born session over from Slack moves
its pushes to Slack instead of splitting them.

The two tests that pinned "the owner's named chats are isolated from
each other" now pin the new rule, plus a guest-sees-nothing check.

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
…s single (#19)

Owner-ordered revision of the AGENTS.md ACL red line: sessions visible =
own ⊕ the identity's web pool ⊕ the owner's IM pool (named operator chats
only); visibility is not delivery — a session pushes only to its single
reply target. The user docs say the same in one sentence.

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
Ship gate for the post-v0.11.2 cycle (36 commits since the tag). New
user-facing capability (minor bump): Slack joins Telegram and Lark as an
IM, and every agent session lives in its own Slack thread. README and
docs/usage*.md were updated in their own commits.

Version lockstep: workspace `Cargo.toml` 0.11.2 -> 0.12.0 (8 workspace
crate lines in `Cargo.lock`, no full re-resolve); `@ccteam/ccteam-ui`
version + `ccteam.engine` + the four `@ccteam/engine-*` optionalDependencies
+ lockfile + `PACKAGE_VERSION` / `ENGINE_VERSION`; embedded `ccteam-ui.tgz`
repacked, two-path reproducible.

Contents:
- local #19 Slack IM: Socket Mode provider (no public URL); thread =
  session (session focus per thread, ownership/project per channel, one
  thread per session, internal follow-ups land in the session's thread);
  guided web setup card (one-click create-app link, token check,
  click-to-allow member id); per-app slash command; `!` command prefix
  in threads; buttons-first next steps (session controls, menu, weighted
  layout, dropdowns for long lists); `/help` as a short grouped list.
- ACL red line revised (owner): the owner's named IM chats share session
  visibility; a session still pushes only to its one reply target.
- Clickable pickers wherever the provider renders buttons (capability on
  the provider, not a platform name in the gateway).
- #209: a running turn's words reach IM and web as it runs; interim
  answers never end a turn; no phantom working turn after a boundary.
- build: async-trait 0.1.92 for rustc 1.99 clippy.

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 4, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-04T17:44:18.125732Z c95017d PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@firstintent
firstintent merged commit 155a788 into main Oct 4, 2026
3 checks passed

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c95017d965

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@@ -5591,7 +5852,7 @@ impl Gateway {
// session's project, so a following /new (and /cd's default) lands in
// the same project you just switched into — not the stale prior one.
self.current_project.set(chat, project);
self.current_session.set(chat, sid.clone());
self.focus_session(chat, sid.clone());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reseed progress after moving a live session

When /use moves a session while its turn is already emitting progress, this updates reply_to and focus but leaves the daemon's cached StatusHandle unchanged. Later Progress events reuse the same status_key, so deliver_progress edits the original message (daemon.rs:1844-1847): within one Slack channel the progress card keeps updating the old thread, and across channels the edit targets an invalid channel/message pair and fails. Clear or reseed the handle when the channel, chat, or thread changes so post-switch output follows the session's unique current reply target.

AGENTS.md reference: AGENTS.md:L57-L57

Useful? React with 👍 / 👎.

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