release: v0.12.0 — Slack, one agent session per thread - #210
Conversation
… 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>
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>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 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()); | |||
There was a problem hiding this comment.
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 👍 / 👎.
Release v0.12.0 — Slack as an IM, one agent session per thread (36 commits since v0.11.2).
What's in it
!(Slack swallows/and allows no app slash command in threads)./helpis a short grouped list.double_must_use).Version lockstep
Workspace 0.11.2 → 0.12.0 (8 crate lines in
Cargo.lock),@ccteam/ccteam-uiversion +ccteam.engine+ the four@ccteam/engine-*+ lockfile +PACKAGE_VERSION/ENGINE_VERSION; embeddedccteam-ui.tgzrepacked, 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_testws_*×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