Skip to content

feat(slack): drive the flagship agent from Slack, set up in the TUI - #3697

Merged
kovtcharov-amd merged 9 commits into
amd:mainfrom
kovtcharov:claude/gaia-slack-tui-integration-f0237d
Sep 24, 2026
Merged

kovtcharov-amd merged 9 commits into
amd:mainfrom
kovtcharov:claude/gaia-slack-tui-integration-f0237d

Conversation

@kovtcharov

@kovtcharov kovtcharov commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor

You can message the flagship GAIA agent from a Slack DM and it answers from your desktop — reading your files, streaming its reply, and sending back files it writes. The TUI offers to connect Slack the first time it sees the app installed, and the whole setup is one click plus two pasted tokens. Shell commands, file writes and code execution still pause for approval, now as Allow/Deny buttons in Slack.

The security argument rests on the agent's existing confirmation gate, and no Slack message or button can disarm it. The bridge refuses to start without an allowlist, answers direct messages only, is pinned to one workspace, and serves exactly one member — it drives one agent with one conversation, so a second member would share the first's history and approvals. Reads stay ungated; the guide says so plainly.

Also fixes gaia telegram on installed wheels, which failed to import because gaia.messaging was never packaged.

Test plan

  • python -m pytest tests/unit/messaging -q — includes test_slack_event_contract.py, which drives the adapter with events from the real handler and translator rather than hand-written ones
  • cd tui && go test ./...
  • python util/lint.py --all
  • gaia slack status on a machine with Slack installed reports it and offers setup
  • gaia slack start --allowed-users U0A,U0B refuses, explaining the shared conversation
  • Needs a real workspace: gaia slack setup, DM the bot, watch the reply stream, approve a gated command, ask for a file back, and leave one prompt unanswered for ten minutes to see it denied

Until now the flagship agent only answered someone sitting at the machine
it runs on. Now you message GAIA from Slack on your phone and it answers
from your desktop — reading your files, searching your documents, and
uploading results back — and the TUI offers to connect it the first time
it notices Slack installed, rather than making you find the feature.

`gaia slack` runs its own long-lived process that owns its own
`gaia-agent` child over the same stdio transport the TUI uses. It is a
peer of the TUI, not a passenger: Slack works whether or not a terminal
is open, and a remote turn never lands in the conversation the local user
is looking at. The transport-agnostic half lives in `messaging/bridge.py`
so Signal (amd#693) is a renderer and a socket, not a second bridge.

Security is the part that decides whether this ships at all. Unlike the
Telegram adapter, which reaches a tool-less AgentSDK, this drives the real
agent — so the argument is not "the tools are absent" but "the dangerous
ones are gated and nothing here can ungate them":

- Shell, file writes and code execution already pause on
  TOOLS_REQUIRING_CONFIRMATION. Those pauses render as Block Kit buttons
  and the click rides the existing `gaia_control` channel, so a message
  can ask for a dangerous tool but never run one unattended.
- Bypass is structurally unreachable. The bridge builds no control message
  of its own; its only channel is decide(), which accepts three values.
- No allowlist, no start. DM-only, workspace-pinned, and the buttons check
  the allowlist too — a workspace admin reading the thread cannot approve a
  command on someone else's machine.
- Reads stay ungated. An allowlisted user can read what the agent can read.
  The setup confirmation and the guide say so plainly instead of implying
  otherwise; `--deny-gated-tools` makes the surface read-only outright.

Setup cannot be fully automatic — a Slack app can only be created by a
human at api.slack.com, and the manifest API needs a token obtained the
same way. So it is one click and two pastes: GAIA generates the manifest,
opens the pre-filled create-app URL, validates both tokens against
auth.test, and stores them in the OS keyring. The copy never claims
otherwise.

The offer is shown once and then only when something changed — a skip is
reconsidered exactly when Slack appears on a machine that lacked it, which
is the "I installed it later" case; `never` is honoured forever and
`/slack` always works.

Also fixes a packaging bug found on the way: `gaia.messaging` had no
__init__.py and was absent from setup.py's package list, so `gaia telegram`
ImportErrors on any installed wheel.

Tested: 165 new Python tests and 20 new Go tests. Two of them pin contracts
a fake cannot: the bridge's wire literals are fed through the real
`gaia_agent.stdio.apply_control`, and the adapter's Slack calls are
introspected against the installed slack_sdk — `files.upload` was retired
in favour of files_upload_v2, and a stub would have kept reporting success
straight through that. Full unit suite and the full Go suite pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions github-actions Bot added documentation Documentation changes dependencies Dependency updates mcp MCP integration changes cli CLI changes tests Test changes tui Go terminal UI (gaia-tui) labels Sep 11, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Request changes

This bridges a Slack DM to the real flagship agent — allowlisted, DM-only, workspace-pinned, with the existing shell/file-write confirmation gate rendered as Allow/Deny buttons. The security design holds up: I found no way for a Slack message or button to reach the agent's bypass verb, and both the message path and the button path check the allowlist and the workspace. No security escalation needed here.

The problem is that two of the three headline user-facing behaviours don't actually work, and the tests can't see it because they hand-build the agent's events instead of using the real ones.

What needs fixing before merge:

  1. The reply never streams. The adapter listens for the wrong field name on the agent's token events, so every streamed character is dropped. Users get a "Thinking…" placeholder that sits still until the whole answer lands at once — the opposite of what the guide promises. Every test that covers streaming feeds the adapter a fake event shape the agent never emits, which is why they pass.

  2. Files never come back. The "ask for a file back and it's uploaded to the thread" feature reads a field that the agent's tool-result events don't contain, in any of the three shapes they actually take. The upload path is unreachable, and the guide, the plan doc and the PR description all claim it works. Same root cause, same blind spot in the tests.

  3. Checking status kills the bridge on Windows. The liveness check uses a POSIX idiom that on Windows terminates the process instead of probing it. Since the TUI runs this check at every launch, a backgrounded bridge dies the next time anyone opens the TUI — and on a stale PID file it can terminate an unrelated process that inherited that ID. There's already a cross-platform helper in the daemon code to reuse.

The fix for all three is the same discipline the PR already applied on the inbound side: it pins the control-message constants against the real agent parser, but nothing pins the outbound event field names. One contract test that drives the adapter with events from the real translator would have caught both #1 and #2.

Also worth resolving: the CLI reference page wasn't updated for the new command, a mid-turn question from the agent renders as nothing in Slack, and the shipped plan doc still describes a flag that doesn't exist.

Real-world evidence

N/A — no evidence bundle was produced for this run, and the PR description shows no captured output for the CLI surfaces it adds.

This matters more than usual here. The test plan lists gaia slack status and gaia slack start but shows neither output, and marks the one thing that would have exercised the bridge — DM the bot, watch the reply stream, get a file back — as "needs a real workspace, not automatable here." The two blocking bugs above are exactly what that run would have surfaced in the first thirty seconds. The verdict rests on static review plus reading the agent's real event contract; nothing in this PR was exercised against a live agent.

🔍 Technical details

🔴 Critical

1. token events carry delta, not text — streaming is dead (src/gaia/messaging/slack/adapter.py:600)

CanonicalTranslator._on_chunk emits {"type": "token", "delta": ...} (src/gaia/ui/sse_translation.py:332). The adapter reads text then token, so throttle.add("") on every chunk. throttle.text stays empty for the whole turn, which also means _note_tool and the status branch keep overwriting the reply (they're gated on not target.throttle.text). The user only sees output when final arrives.

                target.throttle.add(str(event.get("delta") or ""))

tests/unit/messaging/test_slack_adapter.py:3776,3792 and test_bridge.py:3074,3121 all emit {"type": "token", "text": ...}, a shape stdio never produces — hence green.

2. tool_result.data never contains file_path — upload is unreachable (adapter.py:675)

SSEOutputHandler only ever sets result_data to one of three shapes (src/gaia/ui/sse_handler.py:441,482,492): {"type": "file_list", "files": [...]}, {"type": "search_results", "chunks": [...]}, or a render card keyed by kind. When none match, sse_translation.py:337 falls back to {summary, success, summary_truncated, command_output, latency_ms}. No path yields a top-level file_path, so _maybe_upload returns at line 676 every time.

Needs a decision on where the path actually comes from — data["files"] for file_list, or a new field emitted upstream — so no suggestion here. docs/guides/slack.mdx ("anything the agent writes under an upload root … is uploaded"), the plan's §5.1.7, and the PR body all assert the feature works today.

3. os.kill(pid, 0) terminates the process on Windows (src/gaia/messaging/slack/cli.py:329)

Per the os.kill docs, on Windows any sig other than CTRL_C_EVENT/CTRL_BREAK_EVENT goes to TerminateProcess with that value as the exit code — 0 included. status() is called by gaia slack status and by the TUI's launch probe (querySlackCmd(true) in tui/internal/ui/chat/model.go:5201), so on GAIA's primary platform every TUI launch kills a backgrounded bridge. With a stale PID file it terminates whatever process inherited the ID.

src/gaia/daemon/instance.py:120 already has a cross-platform pid_alive via psutil; reuse it rather than adding a fourth copy. (src/gaia/eval/runner.py:84 has the same latent issue — out of scope here, but worth a follow-up.)

🟡 Important

4. No contract test on the outbound event vocabulary (tests/unit/messaging/)

test_bridge_contract.py does exactly the right thing for stdin — feeds the bridge's own output through the real stdio.apply_control. Nothing does the equivalent for stdout, which is why #1 and #2 both shipped. A test that runs CanonicalTranslator over representative SSEOutputHandler events and asserts SlackAdapter._on_agent_event renders each one would close it. Minor: test_terminal_events_match_the_agent_vocabulary (line 3400) asserts against the literal {"final","error"} rather than stdio.TERMINAL_TYPES, so it's a self-agreement test sitting in the contract file.

5. needs_input is unhandled — the turn silently stalls (adapter.py:598-620)

ChatAgent registers a request_user_input loop-control tool (hub/agents/chat/python/gaia_agent_chat/agent.py:1327,2078), and CanonicalTranslator._on_user_input_request emits {"type": "needs_input", ...}. The adapter's if/elif chain has no branch for it, so the question never reaches Slack and request_user_input_blocking waits out its timeout (default 300s, floor 10s) before continuing with __NO_RESPONSE__. The TUI handles this event; Slack doesn't. Either render it as a prompt or refuse it the way --deny-gated-tools refuses a confirmation — silence for five minutes is the worst option.

6. docs/reference/cli.mdx not updated for gaia slack

CLAUDE.md: a new CLI command needs both a src/gaia/cli.py subparser and a docs/reference/cli.mdx entry. gaia telegram has one at cli.mdx:1009-1031; gaia slack has none. The new guide at docs/guides/slack.mdx is good but doesn't substitute — the reference page is where the flag list lives.

🟢 Nits

7. SLACK_HEALTH_PORT is defined but nothing serves it (src/gaia/mcp/ports.py:674) — the docstring says "Slack adapter /healthz probe", and there is no probe and no --health-port flag. Either wire it (Telegram's is at telegram.py:360) or drop both the constant and the docstring claim.

8. The plan doc describes a flag that doesn't exist (docs/plans/slack-tui-integration.mdx, §5.1 item 6) — "Shell and file writes are opt-in at start time via --allow-tools". §7 of the same file explains that this inverted to --deny-gated-tools, so the doc contradicts itself. §5 should be rewritten to the shipped design.

9. Downloaded Slack files are never cleaned up (adapter.py:_download) — tempfile.mkstemp with no unlink leaves a copy of every shared file in the temp dir forever. Telegram has the same leak, so this is a pattern worth fixing in both rather than a regression here.

10. gaia slack decline is missing from the guide's command table (docs/guides/slack.mdx) — the TUI's /slack skip and /slack never both shell out to it, so a user who sees it in gaia slack -h has nowhere to read what it does.

11. The PR description carries a 🤖 Generated with Claude Code footer — CLAUDE.md's "No Claude Attribution of Any Kind" forbids it in PR bodies. Worth stripping before merge.

Strengths

  • The security argument is written down as tests, not prose. test_slack_tool_surface.py parses the adapter's AST to prove it can't build a raw control message, and asserts both the event and interaction handlers check the allowlist and the workspace. That's a genuinely better guard than a grep for "bypass", and it'll survive refactoring.
  • test_bridge_contract.py is the right idea, executed well — feeding the bridge's own output through the real stdio.apply_control is exactly CLAUDE.md's boundary-validity rule. The gap is that it only runs in one direction.
  • bridge.py is a clean seam. Turn queue, throttle, approval marshalling and the synthesized terminal event on a dead child are all transport-agnostic and well-reasoned — the reader's readline-until-empty comment and the _dispatch_terminal_error fallback are the kind of detail that only comes from hitting the failure.
  • gaiaslack.ErrUnanswered correctly distinguishes "couldn't ask" from "not set up", so an older gaia exiting 2 doesn't turn into an offer the machine can't honour.
  • The packaging fix (gaia.messaging missing from setup.py) is a real drive-by catch that unbroke gaia telegram on installed wheels.

Kalin Ovtcharov and others added 6 commits September 13, 2026 18:07
…-integration-f0237d

# Conflicts:
#	tui/internal/ui/components/helpoverlay.go
#	tui/internal/ui/components/helpoverlay_test.go
Three of the bridge's headline behaviours were broken, and the tests could
not see it because they fed the adapter event shapes the agent never emits.
Replies never streamed: the adapter read `text` from token events, and the
translator sends `delta`. Files never came back: it looked for a `file_path`
on tool results, which carry only a summary. And an approval prompt nobody
answered parked the bridge's only agent forever, since the agent disables its
own timeout — every later message just sat "Queued".

- Tokens stream from `delta`. An empty final keeps the text that streamed.
- A written file is uploaded using the path the write tool was CALLED with,
  once its result reports no failure. Read tools never trigger an upload.
- An approval prompt is denied after ten minutes, or at once if Slack refuses
  to post it. A click only answers a prompt that is still pending, so a double
  click or a late one cannot resolve a different prompt.
- A mid-run question is posted and the turn cancelled. The stdio wire cannot
  deliver an answer, and the conversation survives, so a reply continues it.
- Shared files are saved to ~/.gaia/slack/inbox under a sanitized name and the
  agent is told the path. Indexing them in the bridge put the document in an
  index the agent child never sees.
- The bridge serves exactly one member. It drives one agent with one
  conversation, so a second member would read the first's history and
  inherit their "Always allow" grants.

test_slack_event_contract.py drives the adapter with events from the real
SSEOutputHandler and CanonicalTranslator, and fails if the translator emits
a type the adapter neither handles nor deliberately ignores.

Also removes SLACK_HEALTH_PORT, which nothing served, adds the missing
`gaia slack` section to the CLI reference, and brings the guide and plan in
line with the shipped design.
`gaia slack status`, which the TUI runs at every launch, checked the bridge
with `os.kill(pid, 0)` — on Windows that is a console Ctrl+C, not a probe.
Liveness now goes through the daemon's psutil `pid_alive` and also confirms
the process is a Slack bridge, so a stale pid file naming an unrelated
process is neither reported as running nor sent SIGTERM by `stop`. psutil
joins the `slack` extra.

CI:
- Making `gaia.messaging` a real package let pylint reach telegram.py's two
  pre-existing unused `context` arguments; they are now `_context`.
- The Decline tests used a /bin/sh stub without the Windows skip.
- The palette title was never truncated, so at 20 columns it wrapped to two
  rows. Eight commands after merging /provider pushed the box one row past a
  12-row window.
…-integration-f0237d

# Conflicts:
#	tui/internal/ui/chat/model.go
#	tui/internal/ui/chat/palette.go
#	tui/internal/ui/components/helpoverlay.go
#	tui/internal/ui/components/helpoverlay_test.go
A shared file is downloaded with the bot token as a bearer header, from a URL
taken from the event payload. Nothing checked that URL was Slack's, and requests
follows redirects by default, so a URL or a redirect pointing elsewhere would
have carried the token with it. Slack always sends its own file URLs, so this
needs a compromised response to matter — but it is cheap to close.

The URL must now be https on a slack.com or slack-edge.com host, compared on the
parsed hostname so userinfo and ports are stripped first, and a redirect is
refused rather than followed. Tests cover a foreign host, plain http, a
lookalike suffix, a URL that is really a request to another host after the
`@`, and a redirect — and assert no request is made at all in the refused cases.

(cherry picked from commit 9ac570e)
@kovtcharov-amd

Copy link
Copy Markdown
Collaborator

All three blockers were already fixed — the review landed on 55e011de1, and two commits pushed two days later closed every item in it. Nothing in this reply is a new change; it is the verification that was never posted.

The important part is that the two blockers are now pinned by a test that would have caught them. I re-introduced each bug on the current head and confirmed the new contract test fails:

  • Put the streaming read back to text → test_streamed_text_from_the_real_translator_reaches_the_reply fails with assert '' == 'Hello world'.
  • Point the upload back at the tool result instead of the tool call → test_a_real_write_file_event_pair_uploads_the_file fails.

That was the review's actual complaint — not the two field names, but that the tests invented their own event shapes and so couldn't see either bug. The new test builds its events by running the real SSEOutputHandler through the real CanonicalTranslator, so a rename on either side now fails here instead of shipping.

One nit is deliberately still open: files shared over Slack are kept, not cleaned up. They moved out of the system temp dir into ~/.gaia/slack/inbox/, where the agent child can actually open them — so the "leaks into temp forever" shape is gone, but there is still no retention policy. Telegram has the same gap, so it belongs in a pass over both rather than here, which is what the review itself suggested.

tests/unit/messaging/ — 187 passed, 1 skipped.

🔍 Technical details

Verified against head dd585cde2. Fixes landed in feffd62c9 ("make streaming, file return, and approvals actually work") and e7d1b3529 ("Windows-safe liveness").

# Finding Status on head
1 🔴 token carries delta, not text adapter.py:665 reads event.get("delta")
2 🔴 tool_result.data has no file_path Re-sourced: _remember_written_file captures the path from the tool_call args, _maybe_upload consumes target.pending_upload and only checks data["success"] is not False
3 🔴 os.kill(pid, 0) on Windows Gone. _is_bridge_process uses gaia.daemon.instance.pid_alive, and additionally matches the cmdline so a recycled PID isn't mistaken for the bridge
4 🟡 No outbound contract test tests/unit/messaging/test_slack_event_contract.py. Also test_terminal_events_match_the_agent_vocabulary now asserts against sse_translation.TERMINAL_TYPES, not a literal
5 🟡 needs_input unhandled etype == "needs_input" → _refuse_question: posts the question + options, then cancel()s so the turn ends instead of parking for the 300 s timeout
6 🟡 cli.mdx missing gaia slack Present
7 🟢 SLACK_HEALTH_PORT unserved Constant removed
8 🟢 Plan doc cites --allow-tools No occurrences left; reads --deny-gated-tools throughout
9 🟢 Downloaded files never cleaned Partially — see above, intentionally deferred
10 🟢 gaia slack decline missing from guide docs/guides/slack.mdx:130
11 🟢 Claude Code footer in PR body Not present

test_slack_event_contract.py also carries test_every_canonical_type_the_translator_emits_is_handled, which diffs the "type": "..." literals in sse_translation.py against the etype == "..." branches in adapter.py. That is the generalised guard for #5 — a new canonical event type now fails the suite rather than being silently dropped.

Mutation checks were reverted; the working tree is clean and no commit was pushed for this PR.

The review's out-of-scope note on src/gaia/eval/runner.py:84 (same os.kill(pid, 0) idiom) still stands and is untouched here.

@kovtcharov-amd

Copy link
Copy Markdown
Collaborator

Merge this one before #3698 — #3698 already contains every commit on this branch, so landing #3698 first would leave this PR empty and close it as a no-op. @kovtcharov-amd this ordering is the one call a human needs to make here; everything else on this PR is verified ready.

I also pushed two changes so the PR is safe to land on its own:

All five earlier review findings were already fixed on head before I touched it — nothing outstanding.

🔍 Technical details

Stack evidence. git log origin/main..pr/3698 contains this branch's commits (55e011de1, 8ff8801f8, feffd62c9, e7d1b3529) plus an explicit e3f170b72 Merge remote-tracking branch 'fork/claude/gaia-slack-tui-integration-f0237d' into claude/daemon-convergence.

Retargeting is not possible. A PR base must exist in the base repo; gh api repos/amd/gaia/branches/claude/gaia-slack-tui-integration-f0237d returns 404 — both branches live only on the kovtcharov fork. So the dependency can only be expressed as merge order, not as a base change.

Findings verified already-fixed on head:

  • adapter.py:665 — target.throttle.add(str(event.get("delta") or ""))
  • adapter.py:679 — elif etype == "needs_input":
  • adapter.py:768,771 — _maybe_upload with args.get("file_path") or args.get("path")
  • cli.py:323 — _pid_alive() delegates to gaia.daemon.instance.pid_alive rather than os.kill(pid, 0), which on Windows sends a console Ctrl+C

Validation: pytest tests/unit/messaging/ -q → 194 passed, 1 skipped. Full tests/unit/ run matches untouched main exactly (see #3721 for why a local full-suite run is not a usable signal on Windows right now).

Adding /slack pushed the block to three lines, which the help overlay
cannot render correctly: renderCommandsSection replaces the block by
position — the label line plus exactly one continuation — so the third
line leaked into every filtered render.

Nothing conflicted textually. This branch grew the block while amd#3988
independently landed the test that pins its shape, so the defect only
appears once the two meet. Reflowed to two lines, both within the
panel's 53-column maximum.
…cleanly

Both branches reflowed the block to two lines, but placed /slack
differently. Same wording here means no conflict when the stack lands.
@kovtcharov-amd

Copy link
Copy Markdown
Collaborator

Merging current main exposed a real bug this PR had been hiding: the TUI chat help would have rendered a broken Commands list. Fixed and pushed — the Go suite is green again.

Worth stating plainly because it's the interesting part: nothing conflicted. This branch grew the Commands block to three lines; main independently landed the test that pins the block at two. Git merged both without a murmur, and the result was broken. That's exactly why a stale base is worth clearing before the rest of the queue rebases onto it — a clean git merge is not evidence of a working merge.

I also matched #3698's wording for the same block, so the two land without conflicting with each other.

🔍 Technical details

The defect. renderCommandsSection replaces the Commands block by position — the label line plus exactly one continuation — because several unrelated help lines share the same indent and matching on indent alone would eat them. A third line therefore leaks into every filtered render. TestChatHelpCommandsBlockIsExactlyTwoLines (added by #3988, 3af861562) exists precisely to catch this, and it failed on all four jobs: Test (ubuntu|macos|windows-latest) and Coverage.

Before / after:

  Commands    /help /clear /bypass              →  /help /clear /bypass /slack
              /setup /memory /model /provider /agents     /setup /memory /model /provider /agents
              /slack

Both lines stay within the panel's 53-column maximum (the widest existing line in chatHelpText).

Conflict avoidance. #3698 independently reflowed to two lines but put /slack on the second. Matching its exact wording here means the block is byte-identical on both branches, so the stack merges without a conflict on this file.

Validation. go test $(go list ./... | grep -v '/tui/test$') -count=1 from tui/ — all packages pass.

@kovtcharov-amd
kovtcharov-amd added this pull request to the merge queue Sep 24, 2026
Merged via the queue into amd:main with commit a38fe3d Sep 24, 2026
74 checks passed
kovtcharov pushed a commit to kovtcharov/gaia that referenced this pull request Sep 24, 2026
amd#3697 landed on main as a squash, so every Slack file collided with its own
earlier self. This branch carries the later version of each — the native TUI
setup panel rather than the terminal hand-off, `gaia slack connect`, and the
bot-token host guard — so the resolution keeps it.
kovtcharov-amd pushed a commit to kovtcharov/gaia that referenced this pull request Sep 24, 2026
…teps 1-4a) (amd#3698)

**Draft. Steps 1-4a are done. Step 4b — the transport flip itself — was
attempted, measured, and reverted; what it actually costs is now written
down.**

> **Stacked on amd#3697.** The diff against `main` includes the Slack
commit. Review that first; this PR's commits are the four after
`feat(slack)`.

GAIA has two ways to run the flagship agent and was about to grow a
third: the TUI spawns it as a stdio child, the daemon knows how to
supervise it as an HTTP sidecar but never does, and the Slack bridge
spawns a second stdio child. Converging them is subtraction — it deletes
the reasons the other paths exist. Reasoning in
[`docs/plans/daemon-convergence.mdx`](docs/plans/daemon-convergence.mdx).

## The headline: gated tools now work over the daemon

This is the piece the plan originally missed, and it is the
safety-critical one. A gated tool could not run over the daemon **at
all**: the agent's HTTP stream did not park on a confirmation, it
emitted *"this streaming surface cannot collect that yet"* and cancelled
the run, because there was nowhere for an answer to come from. On the Go
side `SSEClient` implemented neither permission interface, so the chat
view could only record intent.

For an agent whose gated set is shell, file writes and code execution,
that is the difference between usable and refusing its most useful
tools. Now: a `tool_decision` endpoint carrying `confirm_id`, a
session-scoped `bypass` endpoint, the stdio transport's own
`PermissionState` attached per turn so "always" grants and bypass
survive the turn boundary, and both Go interfaces implemented. Proven
with a live round trip over the real stream — the agent parks in
`confirm_tool_execution`, the client answers, the run resumes.

## Also fixed

**`gaia-agent --serve` never worked from a pip install.** The console
script pointed past the transport dispatcher at `stdio:main`, so the one
transport nobody could reach was the one the package documents. The
dispatcher now lives in a light `gaia_agent.entry` that imports neither
FastAPI nor the stdio graph until it has chosen — pointing it at
`server:main` would have made the stdio wire import a web framework (an
extras-only dependency) just to learn it was not selected.

**Transport parity.** Claude was refused over HTTP while stdio has
always taken `--use-claude`; and switching model on a live session
answered 409 "start a new session_id", which discards the conversation
and every loaded skill. Both fixed, the model switch reusing stdio's own
`switch_model` rather than growing a second implementation.

## Why 4b is not here

Flipping `catalog.go` to `TransportDaemon` compiles and breaks two
packages: `internal/catalog` (3 tests) and the entire `tui/test` e2e
suite (9 tests). The blocker is **`--mock`** — a user-facing persistent
flag, and the only test double the TUI has. `SetMockBinary` points
"every **subprocess** agent" at a binary; a daemon agent has none.

The way through is known (`tui/test/fakedaemon_test.go` already serves
the daemon control plane against an isolated `GAIA_DAEMON_HOME`; it
needs `/ensure`, `/version` and a streaming `/query`), but that is test
infrastructure plus new semantics for a documented flag, on the path
every user hits at launch. It belongs on its own where it can be
verified against a real daemon and sidecar rather than rushed. Written
up as §4.4.

## Test plan

- [ ] `python -m pytest hub/agents/gaia/python/tests/ -q` — 337 pass
- [ ] `cd tui && go test ./...` — all packages pass, e2e suite included
- [ ] `gaia-agent --serve --port 8149` then `curl localhost:8149/health`
— this is what did not work before
- [ ] Revert the console-script pointer;
`test_the_console_script_points_at_the_dispatcher` fails. The
pre-existing tests all called `server.main` directly, which is why they
stayed green through the bug
- [ ] `test_a_gated_tool_runs_once_the_decision_arrives` — the live
confirmation round trip, the thing §3.4 is about

Pre-existing failures in this environment (verified identical on clean
`main` in a separate worktree): `test_full_tool_bundles`,
`test_gaia_agent::test_manifest_tools_count`,
`test_profilespec_characterization` rows 12-14,
`test_structured_vlm_extraction`, `test_doc_examples_runnable` — all
missing optional extras or a tool-registry count drift, none in this
diff.

No eval run: transport only, no prompt, tool, or agent-loop changes.

## Evidence

Run on this branch, macOS (Apple Silicon).

**`gaia slack` — the new CLI surface:**

```console
$ gaia slack status
Slack app installed on this machine: yes
GAIA configured for Slack:           no
Bridge running (backgrounded):       no

Slack is installed but not connected. Run `gaia slack setup`.

$ gaia slack start --allowed-users U024BE7LH        # no tokens stored yet
❌ Slack is not set up on this machine: no GAIA_SLACK_BOT_TOKEN and no GAIA_SLACK_APP_TOKEN. Run `gaia slack setup` to create the app and store its tokens, or export them directly. Docs: https://amd-gaia.ai/docs/guides/slack

$ GAIA_SLACK_BOT_TOKEN=xoxb-… GAIA_SLACK_APP_TOKEN=xapp-… gaia slack start --allowed-users U0AAAAAAA,U0BBBBBBB
❌ Slack adapter refused to start: more than one allowed user.

This bridge drives a single agent with a single conversation, so every allowed member would share one history — anyone could ask what the others asked or read — and one set of 'Always allow' approvals.
Pass exactly one Slack member ID:

  gaia slack start --allowed-users U024BE7LH

Docs: https://amd-gaia.ai/docs/guides/slack

$ gaia slack setup --print-url --no-browser
https://api.slack.com/apps?new_app=1&manifest_json=%7B%22display_information%22%3A%7B%22name%22%…

$ gaia slack decline --never && gaia slack status
Won't ask about Slack again. `gaia slack setup` still works.
Slack app installed on this machine: yes
GAIA configured for Slack:           no
Bridge running (backgrounded):       no
```

**The two new agent routes, against a live `gaia-agent --serve`:**

```console
$ curl -s localhost:8149/version                 # 2.14 — main's 2.13 added GET /memory
{"apiVersion":"2.14","agentVersion":"0.2.0"}

$ curl -s -X POST .../query/<unknown-run>/tool_decision -d {"decision":"allow"}
{"detail":"No run '11111111-1111-4111-8111-111111111111' is in flight. It may have already finished or been cancelled; the decision was not delivered."}

$ curl -s -X POST .../query/<run>/tool_decision -d {"decision":"maybe"}   # fails closed
{"detail":[{"type":"value_error","loc":["body","decision"],"msg":"Value error, decision must be one of allow, deny, always, got 'maybe'","input":"maybe","ctx":{"error":{}}}]}

$ curl -s -X POST .../sessions/never-seen/bypass -d {"enabled":true}
{"detail":"No session 'never-seen' exists. Send a query on that session first; bypass applies to a conversation, not to the server."}
```

**Not covered:** no Slack workspace is connected here, so `gaia slack
start` stops at
the token check above. The DM round trip — streamed reply, Allow/Deny
buttons, a file
coming back — remains unverified against real Slack.

---------

Co-authored-by: Kalin Ovtcharov <kalin@Kalins-Mac-mini.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cli CLI changes dependencies Dependency updates documentation Documentation changes mcp MCP integration changes tests Test changes tui Go terminal UI (gaia-tui)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants