Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
0c03dcf
Add agent bridge: Gullet sidecar + five read/file/close tools
mlsimon734 Jul 26, 2026
d039acb
Verify the bridge on Chrome; flag stale tab ids after a discard
mlsimon734 Jul 26, 2026
5848aea
Scope the discarded-tab claim to Chrome, note the Firefox gap
mlsimon734 Jul 26, 2026
6308563
Clean up the agent bridge: shared helpers, dead code, idle wakes
mlsimon734 Jul 26, 2026
e971e83
Harden bridge close/undo: privacy context, order, retries, revocation
mlsimon734 Jul 26, 2026
18f8333
Verify the bridge close/undo fixes on Zen; correct the Gecko URL claim
mlsimon734 Jul 26, 2026
654d8fa
Dedupe shared bridge helpers; coalesce per-tab badge repaints
mlsimon734 Jul 26, 2026
b98b94b
Present the bridge as Tabglutton; fix 4-part dev version handling
mlsimon734 Jul 26, 2026
4364f39
Report gullet startup faults instead of exiting before MCP init
mlsimon734 Jul 27, 2026
1b8485c
Keep the bridge reachable across background-page suspension
mlsimon734 Jul 27, 2026
178fd81
Stop the options page clobbering the token or misreporting status
mlsimon734 Jul 27, 2026
40ed05d
Wire the bridge into this repo for Claude Code and Codex
mlsimon734 Jul 27, 2026
c9ea0d4
Release bridge connect waiters without copying the set
mlsimon734 Jul 27, 2026
88b1b98
Stop re-arming the reconnect alarm on every event-page restart
mlsimon734 Jul 27, 2026
bd94225
Add tabs_load, fix bridge reconnect, and share one sidecar port
mlsimon734 Jul 28, 2026
9f0fff7
Stop the reconnect loop bidding up Gecko's failed-connect delay
mlsimon734 Jul 28, 2026
ebfa44c
Probe the bridge port over HTTP before opening a WebSocket
mlsimon734 Jul 28, 2026
1ad6fff
Serialize the undo log and stop the stdio pump blocking on a call
mlsimon734 Jul 28, 2026
1474bc8
Settle the gullet election and close only what the undo log can reverse
mlsimon734 Jul 28, 2026
7ca92d5
Record the unexplained first-call timeout on a large backlog
mlsimon734 Jul 28, 2026
3339918
Reframe BRIDGE.md as a shipped register and compress Phasing
mlsimon734 Jul 28, 2026
7ba57ff
Probe idly every 3s so discovery stops racing the first call's wait
mlsimon734 Jul 29, 2026
c3a1de4
Apply the quality pass: dedup helpers, drop dead code, trim wasted IPC
mlsimon734 Jul 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .codex/config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Project-local Codex config. Applies to sessions started from this repo, the
# same scoping .mcp.json gives Claude Code — no flag required.
#
# The token is NOT stored here. It is read at spawn time from the gitignored
# .env, so this file carries no credential and is safe to commit.

[mcp_servers.tabglutton]
command = "bash"
args = [
"-c",
"export TABGLUTTON_TOKEN=\"$(grep -m1 '^TABGLUTTON_TOKEN=' .env | cut -d= -f2-)\"; exec bun run ./gullet/gullet.ts",
]
startup_timeout_sec = 30
# A first tool call can legitimately hold ~90s: up to 45s waiting for the
# browser to dial in (BRIDGE_CONNECT_WAIT_MS) and up to 45s for the browser to
# answer (BRIDGE_REQUEST_TIMEOUT_MS). Codex's default MCP deadline is 60s,
# which cancels a slow-but-healthy call mid-answer. See gullet/README.md.
tool_timeout_sec = 120
4 changes: 4 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
@@ -1,2 +1,6 @@
WEB_EXT_API_KEY=user:...
WEB_EXT_API_SECRET=...

# Agent bridge. Generate in Tabglutton's options page (Settings → Agent bridge →
# Generate) and paste here; .mcp.json reads it, so no token is ever committed.
TABGLUTTON_TOKEN=
11 changes: 11 additions & 0 deletions .mcp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"mcpServers": {
"tabglutton": {
"command": "bash",
"args": [
"-c",
"export TABGLUTTON_TOKEN=\"$(grep -m1 '^TABGLUTTON_TOKEN=' .env | cut -d= -f2-)\"; exec bun run ./gullet/gullet.ts"
]
}
}
}
45 changes: 40 additions & 5 deletions AGENTS.md

Large diffs are not rendered by default.

469 changes: 469 additions & 0 deletions BRIDGE.md

Large diffs are not rendered by default.

10 changes: 9 additions & 1 deletion build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,15 @@ function writeManifest(target: Target, dist: string): void {
"48": "icons/icon-chomp-48.png",
"128": "icons/icon-chomp-128.png",
};
raw.minimum_chrome_version = "116";
// 120, not 116, because of `alarms`. Chrome clamps an extension alarm to a
// minimum granularity, and that minimum was one minute until 120 dropped it
// to 30s for MV3 (`alarms_api_constants.h`: kMV2ReleaseDelayMinimum vs
// kMV3ReleaseDelayMinimum). The bridge's reconnect alarm is the 30s one, and
// an agent's first tool call waits BRIDGE_CONNECT_WAIT_MS (35s) for a
// browser — so on 116-119 a sleeping worker would be woken a minute later
// and the call would already have answered "no browser is connected".
// Unpacked builds never reproduce it: kDevDelayMinimum is 1s.
raw.minimum_chrome_version = "120";
}
writeFileSync(`${dist}/manifest.json`, `${JSON.stringify(raw, null, 2)}\n`);
}
Expand Down
182 changes: 182 additions & 0 deletions gullet/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
# Gullet

The sidecar half of Tabglutton's agent bridge — the pipe tab content passes through on its
way to a coding agent. One side is an **MCP server over stdio**, spawned by whatever agent
harness you use; the other is a **WebSocket server on loopback** that browsers running
Tabglutton dial into.

Architecture, trust boundary, and phasing live in [`../BRIDGE.md`](../BRIDGE.md). This file
is the setup guide.

```
Claude Code ──MCP (stdio)──► Gullet ──WebSocket (127.0.0.1:4588)──► Zen / Firefox / Chrome
```

Zero dependencies: it runs on Bun's built-ins alone, so there is nothing to install beyond
having the repo checked out.

**Two names, one product.** "Gullet" is the internal name for this sidecar; everything a
user or an agent sees says **Tabglutton**. So the MCP server registers as `tabglutton`, the
tools appear under that namespace, and the token is `TABGLUTTON_TOKEN`. `GULLET_TOKEN` and
`GULLET_PORT` still work as aliases.

## Setup

1. **Turn the bridge on in the browser.** Tabglutton → Settings → _Agent bridge_ → enable,
then **Generate** a token and copy it. The bridge is off until you do this, and no
socket is opened while it is off.

2. **Register Gullet with your agent.** The settings page renders a ready-made config with
your token and port filled in — _Copy config_ and paste it into `.mcp.json` (or
`~/.claude.json`), replacing the placeholder path with wherever you cloned this repo:

```json
{
"mcpServers": {
"tabglutton": {
"command": "bun",
"args": ["run", "/path/to/tabglutton/gullet/gullet.ts", "--port", "4588"],
"env": { "TABGLUTTON_TOKEN": "<token from the settings page>" }
}
}
}
```

For Claude Code specifically:

```sh
claude mcp add tabglutton --env TABGLUTTON_TOKEN=<token> -- bun run /path/to/tabglutton/gullet/gullet.ts
```

3. **Start a session.** The agent spawns Gullet, Gullet opens the port, and the extension's
reconnect loop finds it — typically within a few seconds (it re-probes the port every 3s
while the browser's extension page is awake), worst case ~30 seconds (the alarm cadence,
when the page had suspended). The toolbar badge shows a terracotta dot while the
connection is live. When the session ends, Gullet exits and the extension goes back to
idle dialling.

There is no app to launch and no per-session step. Multiple browsers can be connected at
once — a Zen window and a Chrome profile, say — and each tool call picks one with the
`browser` argument.

## Configuration

| Flag | Env | Default | Notes |
| --------- | ------------------ | ------- | ---------------------------------------------------------------------------------- |
| `--port` | `TABGLUTTON_PORT` | `4588` | Must match the port in Tabglutton's settings. |
| `--token` | `TABGLUTTON_TOKEN` | — | Required. Prefer the env var: process arguments are readable by other local users. |

Diagnostics go to **stderr**; stdout is the MCP transport and carries nothing else.

## Tools

| Tool | What it does |
| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `tabs_list` | Metadata for every open tab — id, title, url, `lastAccessed`, `discarded`, `pinned`, `active`, window, and `hidden` on Firefox/Zen. No page content, so it stays cheap across hundreds of tabs. |
| `tabs_load` | Reloads discarded tabs so they can be read, ≤20 per call, a few at a time. Off by default — see below. |
| `tab_read` | Extracts one loaded tab as clean markdown via Defuddle. |
| `tab_clip` | Files a tab into Obsidian exactly as the popup's Devour does. Optionally closes it after. |
| `tabs_close` | Closes tabs. Records the batch first and returns a `batchId`. |
| `undo_close` | Reopens a recorded batch. |

Deliberately absent: navigate, click, type, evaluate. The agent can read what you already
chose to open, file it, and clean up — it cannot act as you. Adding anything richer means
revisiting the prompt-injection posture in `BRIDGE.md` first.

`tabs_load` is the one tool that acts on a page rather than observing it, so it has its own
switch — **Agent bridge → "Let agents load unloaded tabs"** in Tabglutton's settings — and
enabling the bridge does not enable it. Until it is on, the tool answers `not-enabled` with
that instruction, so an agent can tell you what to flip. Even on, all it does is reload a
tab you already opened; the URL never comes from the agent.

`tabs_list` with no `browser` argument fans out across every connected browser and tags
each tab with its origin. The tab-scoped tools refuse to guess between two browsers, since
tab ids only mean something within one.

## Suggested workflow

Triage on metadata first. In a 300-tab backlog most tabs are discarded (unloaded), and
`tab_read` cannot reach those. Cutting on title, URL, and age before reading anything is
what makes triaging that many tabs affordable in tokens — and it also keeps the survivors
few enough to be worth waking.

Then wake the survivors in batches: one `tabs_load` per 20 tabs, not one per tab. Loads run
three at a time under a fixed budget per call, so a batch is bounded by the slowest few
pages rather than by their sum, and each tab comes back `ready`, `pending` (still loading,
or not reached — ask again), or `failed` (gone, or not an http(s) page). Read the `ready`
ones. With loading switched off, `tab_read` fails with `tab-discarded` instead and those
tabs are yours to open by hand.

## Troubleshooting

**Several agent sessions at once.** Supported, and nothing needs configuring. The first
Gullet to start binds the port and serves the browser; later ones attach to it and proxy
through, so every session sees the same tabs. When the one holding the port exits, the
others re-race and one takes over within a second. You may see more `bun run gullet` processes
than you have sessions — some MCP clients spawn more than one — which is harmless now that
losing the race is not fatal.

**"No browser is connected."** The bridge is off in Tabglutton's settings, no token has
been generated, the ports do not match, or the browser has not re-dialled yet — a first
call waits up to 45s for the browser's backstop alarm (30s cadence, on Firefox too) to
fire and the dial to land, so this answer normally means configuration, not timing. The
settings page shows live connection status.

**Tool calls cancelled by the client.** A first call can legitimately hold for the 45s
connect wait, and a slow method holds for its own 45s request budget after that — ~90s
worst case for one tool call. Most MCP clients default to a 60s deadline (the MCP
TypeScript SDK and Codex both do); give your client at least 100s or a slow-but-healthy
call surfaces as a bare cancellation instead of an answer. Claude Code: `MCP_TOOL_TIMEOUT`
(milliseconds). Codex: `tool_timeout_sec` per server — this repo's `.codex/config.toml`
sets it to 120.

**The browser dials but nothing reaches Gullet.** If the extension's console shows a
WebSocket close code of **1015** and Gullet logs nothing at all, the extension CSP is
upgrading `ws://` to `wss://` and Gullet is being handed a TLS ClientHello. `manifest.json`
must declare `content_security_policy.extension_pages` explicitly — Firefox's MV3 default
includes `upgrade-insecure-requests`, which does this to loopback WebSockets as well.

**Discovery takes minutes instead of seconds.** If the extension only connects long after
Gullet started — or not at all — inspect the extension's background console via
`about:debugging#/runtime/this-firefox` → Tabglutton → _Inspect_ (its `[tabglutton]`
lines do not appear in the Browser Console). `bridge dial timed out after 120000ms`
repeating against a Gullet that answers `curl` instantly means the browser itself cannot
complete a loopback WebSocket: verified live in a ~1,050-tab Zen where dials sat two full
minutes without a SYN ever reaching the wire, while the Browser Console (Cmd-Shift-J)
showed Firefox's own Push service failing with
`PushServiceWebSocket … NS_ERROR_SOCKET_CREATE_FAILED`. The bridge is not misconfigured
and no setting fixes it — restart the browser.

**"Token mismatch."** `TABGLUTTON_TOKEN` and the token in Tabglutton's settings differ.
Regenerating the token in settings invalidates any sidecar still holding the old one.

**"could not listen on 127.0.0.1:4588"** Another Gullet — usually from a second agent
session — already holds the port. One sidecar can serve several browsers, but two sidecars
cannot share a port; give the second one a different `--port` and match it in settings.

**Nothing in the logs.** Gullet writes to stderr, which most agent harnesses hide. Run it
by hand to watch it:

```sh
TABGLUTTON_TOKEN=<token> bun run gullet/gullet.ts
```

Then poke the socket directly:

```sh
bunx wscat -c ws://127.0.0.1:4588 -H 'Origin: moz-extension://test'
```

You should get a `challenge` frame back. Without the `Origin` header the upgrade is
refused with 403 — that check is what stops a hostile web page from opening the socket
from inside your browser.

## Development

```sh
bun run typecheck:gullet # from the repo root
bun test # protocol, config, selection, MCP, and a live-socket hub test
```

The wire contract lives in [`../src/bridge-protocol.ts`](../src/bridge-protocol.ts) and is
imported by both halves, so extension and sidecar are typechecked against one definition.
7 changes: 7 additions & 0 deletions gullet/gullet.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#!/usr/bin/env bun
// Gullet — the pipe tab content passes through on its way to an agent.
// Entry point referenced from agent MCP configs; see gullet/README.md.

import { main } from "./src/main.js";

process.exit(await main(Bun.argv.slice(2), Bun.env));
13 changes: 13 additions & 0 deletions gullet/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"name": "gullet",
"version": "0.1.0",
"private": true,
"description": "Tabglutton's agent bridge: an MCP server over stdio, a WebSocket hub on loopback.",
"bin": {
"gullet": "./gullet.ts"
},
"type": "module",
"scripts": {
"typecheck": "bunx tsc --noEmit -p tsconfig.json"
}
}
Loading