SLOPSHOPPER

maiterm-tab

maiTerm's link to the Claude Code session in its tab: hook events reach maiTerm carrying the tab they came from.

newguardnetworktimer
A shopper browsing a rack in a slop shop
README

<table><tr> <td><a href="https://maiterm.dev/"><img src="src-tauri/icons/icon.png" alt="maiTerm" width="128" /></a></td> <td><h1><a href="https://maiterm.dev/">maiTerm</a></h1>A terminal built for AI workflows — not AI built into a terminal.<br><a href="https://maiterm.dev/">Documentation & Screenshots</a></td> </tr></table>

A terminal emulator built with Tauri 2 + Svelte 5, designed to make terminal-based AI workflows better and more organized. maiTerm is not "AI in a terminal" — there is no built-in LLM, no chat sidebar, no magic autocomplete. Instead, it's a proper terminal that understands what AI coding agents are doing and gives you the tools to manage them.

Our initial focus is on Claude Code integrations:

  • Real-time session tracking — hooks report Claude's state (active, idle, needs permission) with live tab indicators
  • Auto-resume — automatically reconnects to your last Claude session when you open a tab, using hooks-captured session IDs
  • Tab state awareness — know at a glance which tabs have Claude thinking (pulsing dot), waiting for input (green dot), or needing permission (lock icon)
  • Workspace organization — group related Claude sessions by project; split panes to run multiple agents side by side
  • Agent Bridge — bridge two running Claude sessions (local + remote, or two related projects) so they collaborate directly while you stay the one making the calls
  • Scrollback persistence — full terminal state in SQLite survives restarts, so you never lose Claude's output
  • SSH session cloning — split into a second shell at the same remote CWD while Claude works in the first
  • IDE integration — MCP server with 25+ tools for file operations, diff review, editor control, notes, and multi-agent coordination
  • SSH MCP bridge — reverse SSH tunnel exposes local IDE tools to Claude Code running on remote servers

maiTerm is fully written by AI (Claude), with human engineering direction and architectural rails.

Runs on macOS, Windows, and Linux.

Why maiTerm?

Other terminals give you tabs and splits. maiTerm gives you a system for managing the chaos of real AI-assisted development — where you're juggling multiple Claude sessions across projects, SSHing into servers, and trying to remember what you were doing three hours ago.

Organize by workspace, not just tabs

Group your terminals by project. Each workspace has its own pane layout, tabs, and context. Switch between "ACME Project" and "Production Server" without losing your place in either.

Workspaces and tabs

Bridge two Claude agents together

Run two Claude Code sessions — say one local and one SSH'd into a related service — and bridge them so they collaborate directly. One agent asks the other questions, requests research, or shares context; replies arrive as new turns in each session. maiTerm forks the peer into a split pane so you see both sides, stamps every message with the sender's real identity (so neither agent can pose as you), and keeps the agents deferring to you for the decisions. Bridges persist across restarts.

Notes on every tab

Each tab has its own markdown notes panel. Track TODOs, paste connection strings, jot down what you're debugging — right next to the terminal doing the work. Workspace-level notes too. Your Claude Code agent shares the same panel — ask it to write down what it just did, keep a running TODO, or tidy your notes, and it edits them directly via MCP.

Per-tab notes panel

Built-in editor, integrated with your terminal

Click any file path in terminal output to open it in an editor tab. Works over SSH too — remote files load transparently via SCP. Syntax highlighting for 50+ languages, image/PDF preview, and Claude Code's diff review all live alongside your terminal tabs.

Code editor tab

Deep clone everything

Duplicate a tab and get everything: scrollback history, CWD, SSH session, Claude resume command, tab name, notes, trigger variables. Or shallow clone for just the name and CWD. New tabs automatically inherit the workspace's most common working directory.

Tab names that make sense

Tabs auto-update from terminal titles (OSC 0/2), but you can override with your own name — or combine both. Rename a tab "billing API debug" and it stays that way even as the terminal title changes underneath.

Per-tab command history

Every tab maintains its own shell history. Clone a tab and its history comes with it. No more scrolling through one giant shared history trying to find the command you ran in a specific context.

Auto-resume: never lose a session

Reboot your machine, restart maiTerm — everything comes back. Terminal scrollback, tab layout, workspace state. Claude Code sessions auto-resume using hooks-captured session IDs — no manual setup needed. SSH sessions reconnect to the right remote CWD. Pick up exactly where you left off.

Archive and restore tabs

Done with a Claude session but not ready to lose it? Archive the tab. It disappears from your tab bar but preserves everything — scrollback, notes, trigger state. Restore it later and resume right where you left off. Perfect for when /rename and --continue aren't enough.

Stay in the loop with notifications

Triggers watch your terminal output for patterns — Claude asking a question, a long build finishing, context compaction starting. Get notified via in-app toasts or OS notifications, with deep-linking straight to the right workspace and tab.

Features

Terminal

  • alacritty_terminal + xterm.js — Rust-based VTE parsing, buffering, and scrollback in the backend; xterm.js is a thin DOM renderer for the visible viewport (~60fps ANSI frames). Scrollback lives in the backend, so the frontend only ever paints one screen — no GPU rendering needed
  • Split panes — horizontal and vertical splits, drag to resize, fully recursive
  • Multiple workspaces — named workspaces with independent pane layouts, reorderable via drag and drop
  • Workspace suspend/resume — suspend idle workspaces to free resources, auto-suspend after configurable timeout
  • Lazy terminal activation — PTYs only spawn when a tab becomes visible
  • Multiple tabs — per-pane tabs with activity indicators and completion detection
  • Scrollback persistence — SQLite (WAL mode) with dirty tracking and staggered saves
  • SSH session cloning — split an SSH session to get a second shell at the same remote CWD
  • SSH drop recovery — an unexpectedly dropped SSH session (vs. a clean logout) keeps its title and shows a one-click reconnect badge that replays the connection and returns to the same remote directory
  • Tab overflow menu — tabs scrolled out of view collapse into a menu so you can jump straight to any of them
  • File drop — drag files onto a terminal to paste paths; over SSH they're SCP'd to the remote CWD with live, cancelable upload progress
  • Multi-window — open additional windows, duplicate windows with full tab context

Shell Integration

  • OSC 133 (FinalTerm) — command start/finish detection for tab completion indicators
  • OSC 7 — directory tracking (remote CWD awareness through SSH)
  • OSC 8 file hyperlinks — l command wraps ls to emit clickable file links; underline appears on hover
  • l shell function — always available in local shells; also injectable into remote shells via context menu
  • Tab indicators — completed (checkmark/cross), at-prompt (›), and activity dot; no spinner (interactive programs stay stable)
  • Remote install — one-liner session setup or permanent ~/.bashrc/~/.zshrc installation

Code Editor

  • CodeMirror 6 — full-featured editor in tabs alongside terminal tabs
  • Click to open — click any file path in terminal output to open it in an editor tab
  • Cmd+O — file dialog that defaults to the active terminal's CWD
  • Local + remote files — remote files read/written via SCP, transparent to the user
  • Diff review — side-by-side diff tabs with accept/reject workflow (used by Claude Code integration)
  • Image preview — PNG, JPG, GIF, WebP, SVG, AVIF, BMP, ICO with zoom controls (fit, +/-, presets)
  • 50+ languages — syntax highlighting via CodeMirror 6 first-class packages and legacy StreamLanguage modes
  • Language detection — by extension, known filename (.bashrc, Dockerfile), and shebang line
  • Find/replace — Cmd+F, positioned at top of editor
  • Save — Cmd+S writes local or remote via SCP; dirty indicator in tab
  • Close protection — inline confirm (no window.confirm) for unsaved changes
  • Portal pattern — editor survives split tree changes (same as terminals)

Claude Code IDE Integration

  • MCP server — WebSocket, SSE, and Streamable HTTP transports expose 25+ tools to Claude Code CLI
  • Hooks integration — HTTP lifecycle hooks (SessionStart/End, PreToolUse/PostToolUse, Stop, Notification, PreCompact) provide real-time session state tracking
  • Tab indicators — pulsing accent dot (Claude active/thinking), green dot (idle/waiting for input), lock icon (needs permission)
  • Auto-resume — hooks capture session IDs automatically; tabs resume Claude sessions on restart
  • Diff review — Claude proposes file changes; you accept or reject in a side-by-side diff tab
  • Notes & workspace tools — per-tab notes, workspace notes, note search, notes panel control via MCP
  • Multi-agent coordination — getClaudeSessions exposes all active Claude sessions (state, tool, model, cwd) across tabs
  • Agent Bridge — bridge two running Claude sessions so they talk to each other directly (sendToBridgedAgent / getBridgedAgent); fork-into-split or link existing tabs, async messaging with maiTerm-stamped identities, persists across restarts
  • SSH MCP bridge — reverse SSH tunnel (-R 0:localhost:port) exposes local IDE tools to remote Claude Code; ControlMaster mux support, bridge status indicator
  • Auto-discovery — writes lock file to ~/.claude/ide/ and registers in ~/.claude.json for automatic connection
  • /maiterm skill — auto-installed Claude Code skill for fast slash-command access to maiTerm tools (/maiterm notes, /maiterm tabs, /maiterm diag, …); /maiterm statusline installs a recommended status line (host · cwd · branch · model · effort · context %), local and over SSH
  • Dev/prod isolation — dev builds register as aiterm-dev with display name "maiTermDev"

Trigger System

  • Regex triggers — watch terminal output for patterns, fire actions
  • Match modes — regex, plain text, or variable-condition expressions
  • Actions — notify (toast or OS notification), send_command (write to PTY), enable_auto_resume, set_tab_state
  • Variables — capture groups mapped to named variables (%varName), persisted per tab
  • Variable interpolation — used in tab titles, auto-resume commands, notification bodies
  • Variable conditions — expression parser supporting &&, ||, !, ==, != operators
  • Custom triggers — Claude-specific triggers replaced by hooks; trigger engine available for user-defined patterns

Notifications

  • Three modes — auto (in-app when focused, OS when not), in_app, native, disabled
  • Deep-linking — clicking a toast or OS notification navigates to the source workspace and tab
  • Toast UI — max 3 visible, configurable auto-dismiss duration, Tokyo Night styled
  • Sound alerts — built-in chirp or system sounds with configurable volume
  • Command completion — notifies when long-running commands finish (configurable minimum duration)

Notes

  • Per-tab notes — markdown or plain text notes panel per terminal/editor tab
  • Workspace notes — notes scoped to the whole workspace
  • Claude-maintained — ask your Claude Code agent to read, write, update, organize, merge, and clean up notes via MCP tools
  • Interactive checkboxes — rendered in preview mode
  • Modes — edit and preview; state persisted per tab

Themes

  • 15 built-in themes — Tokyo Night (default), Dracula, Solarized Dark, Solarized Light, Tokyo Night Day, Catppuccin Latte, Gruvbox Light, One Light, GitHub Light, Nord, Gruvbox Dark, Monokai, Catppuccin Mocha, One Dark, macOS Pro
  • Custom themes — create and edit via theme editor in preferences
  • Separate UI and terminal colors — full control over both

Workspace Sidebar

  • Sort order — default (drag and drop), alphabetical, or recent activity
  • Tab count — optional display of tab count after workspace names
  • Recent workspaces — collapsible section, toggleable

Keyboard Shortcuts

ShortcutAction
Cmd+TNew tab
Cmd+WClose tab (or pane if last tab)
Cmd+1–9Switch to tab
Cmd+Shift+[Previous tab
Cmd+Shift+]Next tab
Cmd+Shift+TDuplicate tab
Cmd+Shift+RReload tab (duplicate + close)
Cmd+DSplit pane (duplicate tab)
Cmd+NNew workspace
Cmd+OOpen file in editor tab
Cmd+SSave file (editor tabs)
Cmd+FFind/replace (editor tabs)
Cmd+,Preferences
Cmd+/Help
Cmd+Shift+NNotes panel toggle

Prerequisites

All platforms require:

macOS

  • macOS 13+
  • Xcode Command Line Tools (xcode-select --install)

Windows

  • Windows 10/11
  • Visual Studio Build Tools — select "Desktop development with C++" workload
  • WebView2 (pre-installed on Windows 10/11; bundled by the NSIS installer for end users)

Linux

Development

# Install dependencies
npm install

# Full app dev (frontend + Rust backend + MCP bridge)
npm run tauri:dev

# Frontend only (no Tauri)
npm run dev

# Type checking
npm run check

# Rust compilation check (run from src-tauri/)
cargo check

Building

npm run tauri:build

Build output by platform:

PlatformFormatOutput path
macOSDMGsrc-tauri/target/release/bundle/dmg/
WindowsNSIS installersrc-tauri/target/release/bundle/nsis/
Linux.debsrc-tauri/target/release/bundle/deb/

macOS post-build

After building on macOS, set the DMG volume icon:

./scripts/set-dmg-icon.sh

CI

GitHub Actions workflows build automatically on push to main and on tags:

  • .github/workflows/build-linux.yml — Ubuntu, produces .deb
  • .github/workflows/build-windows.yml — Windows, produces NSIS .exe installer

Project Structure

src/                          # Frontend (Svelte 5 / TypeScript)
├── routes/
│   ├── +layout.svelte        # App shell, keyboard shortcuts, modals
│   └── +page.svelte          # Main terminal/editor view, portal rendering
└── lib/
    ├── components/
    │   ├── editor/           # EditorPane, DiffPane (CodeMirror 6)
    │   ├── terminal/         # TerminalPane, TerminalTabs
    │   ├── workspace/        # WorkspaceSidebar
    │   └── pane/             # SplitPane
    ├── stores/               # Svelte 5 runes stores
    │   ├── workspaces.svelte.ts
    │   ├── terminals.svelte.ts
    │   ├── preferences.svelte.ts
    │   ├── triggers.svelte.ts
    │   ├── activity.svelte.ts
    │   ├── claudeCode.svelte.ts    # Claude Code IDE tool handler
    │   ├── claudeState.svelte.ts   # Claude session state from hooks
    │   ├── sshMcpBridge.svelte.ts  # SSH MCP bridge orchestration
    │   ├── editorRegistry.svelte.ts # Editor state tracking
    │   ├── notifications.svelte.ts  # Command completion notifications
    │   ├── toasts.svelte.ts
    │   └── notificationDispatch.ts
    ├── triggers/
    │   ├── defaults.ts       # Trigger defaults (empty — Claude triggers replaced by hooks)
    │   └── variableCondition.ts  # Variable expression parser
    ├── themes/
    │   └── index.ts          # Theme definitions + application
    ├── utils/
    │   ├── editorTheme.ts    # Tokyo Night CodeMirror theme
    │   ├── filePathDetector.ts  # xterm.js link provider
    │   ├── languageDetect.ts    # Extension → language + CM6 loader
    │   ├── openFile.ts          # Orchestrates file open flow
    │   ├── shellIntegration.ts  # Remote shell hook snippets
    │   ├── promptPattern.ts     # PS1-like pattern matching
    │   ├── platform.ts          # OS detection, modifier key helpers
    │   └── ansi.ts              # ANSI escape stripping
    └── tauri/
        ├── commands.ts       # invoke() wrappers
        └── types.ts          # TypeScript interfaces

src-tauri/src/                # Backend (Rust)
├── lib.rs                    # App setup, command registration
├── commands/
│   ├── workspace.rs          # State CRUD
│   ├── editor.rs             # File read/write, SCP, editor tab creation
│   ├── terminal.rs           # PTY spawn/write/resize/kill
│   ├── window.rs             # Multi-window management
│   └── claude_code.rs        # Claude Code tool response handler
├── claude_code/              # Claude Code IDE integration
│   ├── server.rs             # WebSocket + SSE + Streamable HTTP MCP server
│   ├── protocol.rs           # JSON-RPC protocol, tool definitions
│   └── lockfile.rs           # Lock file, MCP registration, hook settings
├── terminal/                 # Terminal backend (alacritty_terminal)
│   ├── handle.rs             # TerminalHandle, VTE processor
│   ├── render.rs             # Grid → ANSI viewport renderer (~60fps)
│   ├── osc.rs                # OscInterceptor (OSC 7/9/133/633/1337)
│   ├── search.rs             # Buffer search via RegexSearch
│   └── serialize.rs          # Buffer serialization/restore
├── state/
│   ├── workspace.rs          # Data structures
│   ├── app_state.rs          # Global state container
│   └── persistence.rs        # JSON file storage
└── pty/
    └── manager.rs            # PTY management, shell integration injection

Tech Stack

LayerTechnology
FrontendSvelte 5 (runes), SvelteKit, TypeScript
BackendRust, Tauri 2
Terminalalacritty_terminal (Rust VTE parser + buffer) with xterm.js as thin DOM renderer
EditorCodeMirror 6 (+ MergeView for diffs)
PTYportable-pty
ScrollbackSQLite (WAL mode) via rusqlite
Stateparking_lot RwLock

Data Model

Workspace
├── id, name
├── panes: Pane[]
├── active_pane_id
├── split_root: SplitNode (binary tree)
└── notes: WorkspaceNote[]

Pane
├── id, name
├── tabs: Tab[]           # terminal, editor, or diff tabs
└── active_tab_id

Tab
├── id, name, custom_name
├── tab_type: 'terminal' | 'editor' | 'diff'
├── pty_id                # terminal tabs
├── editor_file           # editor tabs (path, remote info, language)
├── diff_context          # diff tabs (request_id, file_path, old/new content)
├── scrollback
├── notes, notes_open, notes_mode
└── trigger_variables

Trigger
├── pattern (regex), match_mode (regex/plain_text/variable)
├── actions, variables
├── enabled, cooldown, workspaces scope
└── default_id            # links to built-in template

Preferences
├── theme, custom_themes
├── font_size, font_family, cursor_style, cursor_blink
├── clone_*, notification_mode, shell_integration
├── notification_sound, notification_volume, toast_duration
├── prompt_patterns, triggers
├── claude_code_ide, claude_code_ide_ssh
└── workspace sidebar options

Theme

Default: Tokyo Night. 15 built-in themes + custom theme support.

--bg-dark:   #1a1b26   /* main background */
--bg-medium: #24283b   /* elevated surfaces */
--bg-light:  #414868   /* borders, hover */
--fg:        #c0caf5   /* primary text */
--fg-dim:    #565f89   /* secondary text */
--accent:    #7aa2f7   /* interactive elements */

Privacy

maiTerm checks for updates on launch and hourly while running. Those checks pass through updates.maiterm.dev, which counts them so we can estimate how many people use maiTerm.

What's counted is deliberately minimal and anonymous:

  • No IP address, hostname, username, cookie, or device identifier is stored.
  • To turn the hourly checks into a unique-users-per-day figure without keeping any identifier, the count uses the Plausible approach: a one-way hash of daily_secret_salt + IP + user-agent. The salt is random, rotates every day, and is discarded — so a hash can't be reversed to an IP or linked across days.
  • Only the date, app version, and OS/arch are retained, in aggregate.

Opt out: disable Preferences → Updates → Automatically check for updates. No update check means no ping, and nothing is counted.

The counter is a small Cloudflare Worker; its full source is in update-worker/.

Source 1 files
hooks/register.ts 449 lines
1import type { EngineInterface, PermissionRequestDecision, Register } from 'claude-code'
2
3// maiTerm's link to the Claude Code session running in one of its tabs.
4//
5// maiTerm writes this folder into its data directory, and on an ssh host into
6// ~/.maiterm/claude-mod, and names it in every tab's CLAUDE_CODE_PLUGIN_DIRS
7// (claude_code/claude_mod.rs). It forwards the same hook events maiTerm's settings.json hooks
8// send to POST /hooks, with two differences that are the point: every event names its tab
9// (`tab_id=`), so nothing has to be inferred from a session id, and it says it came from here
10// (`via=mod`), so the server drops the settings hooks' copy of each one. A Claude Code too old for mods never loads this, and its settings hooks
11// run exactly as before.
12
13// Tested against 2.1.295; the API is early access and may move between releases.
14const MIN_VERSION = [2, 1, 290]
15
16type Link = { tab: string; port: string; auth: string }
17
18let link: Promise<Link | null> | undefined
19
20function isAtLeast(version: string, min: number[]): boolean {
21  const parts = version.split(/[.-]/).map(n => Number.parseInt(n, 10))
22  for (let i = 0; i < min.length; i++) {
23    const have = parts[i] ?? 0
24    const want = min[i] ?? 0
25    if (Number.isNaN(have) || have < want) return false
26    if (have > want) return true
27  }
28  return true
29}
30
31async function load($: EngineInterface): Promise<Link | null> {
32  const [tab, port, auth] = await Promise.all([
33    $.env.get('MAITERM_TAB_ID'),
34    $.env.get('MAITERM_PORT'),
35    $.env.get('MAITERM_AUTH'),
36  ])
37  if (!tab || !port || !auth) return null
38  const { version } = await $.session.version()
39  if (!isAtLeast(version, MIN_VERSION)) return null
40  // Tells maiTerm's SessionStart/SessionEnd command hooks, which run beneath this mod and
41  // inherit this process's environment, that this mod speaks for the tab.
42  await $.env.set('MAITERM_VIA_MOD', '1')
43  return { tab, port, auth }
44}
45
46// Resolved once per module load. A load that fails is retried by the next event rather than
47// leaving the tab unlinked for the rest of the session.
48function linkOf($: EngineInterface): Promise<Link | null> {
49  link ??= load($).catch(() => {
50    link = undefined
51    return null
52  })
53  return link
54}
55
56// POSTs one hook event; resolves the reply body, or undefined when maiTerm did not answer.
57// Never rejects: a hook that throws would be skipped, and its event lost.
58async function send($: EngineInterface, to: Link, event: unknown, query = ''): Promise<string | undefined> {
59  try {
60    const r = await $.http.fetch(
61      `http://127.0.0.1:${to.port}/hooks?via=mod&tab_id=${encodeURIComponent(to.tab)}${query}`,
62      {
63        method: 'POST',
64        headers: { 'x-claude-code-ide-authorization': to.auth, 'content-type': 'application/json' },
65        body: JSON.stringify(event),
66      },
67    )
68    return r.ok ? r.text : undefined
69  } catch {
70    return undefined
71  }
72}
73
74// --- Answers from maiTerm (claude_code/mod_asks.rs) ---------------------------------------
75//
76// A permission dialog or an AskUserQuestion selector can be answered from maiTerm (the phone,
77// the Loom, the Overlord) as well as at the desktop. The hook behind it registers an ask with
78// the event it sends, then waits on GET /hooks/ask in short rounds: one fetch dies at about 30 s,
79// and a round this short also lets maiTerm tell a waiting hook from a gone one. A key pressed at
80// the desktop still wins; Claude then abandons the hook, which aborts its fetch.
81
82let askSeq = 0
83
84function newAskId(): string {
85  askSeq += 1
86  return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}-${askSeq}`
87}
88
89// Resolves maiTerm's answer once it gives one, or undefined when maiTerm stops holding the ask
90// or can't be reached. `isDone` ends the wait between rounds once the prompt is settled.
91async function awaitAnswer($: EngineInterface, to: Link, ask: string, isDone: () => boolean): Promise<unknown> {
92  const url = `http://127.0.0.1:${to.port}/hooks/ask?id=${encodeURIComponent(ask)}&wait=10`
93  while (!isDone()) {
94    let r
95    try {
96      r = await $.http.fetch(url, { headers: { 'x-claude-code-ide-authorization': to.auth } })
97    } catch {
98      return undefined
99    }
100    if (r.status === 204) continue
101    if (r.status !== 200) return undefined
102    try {
103      return JSON.parse(r.text)
104    } catch {
105      return undefined
106    }
107  }
108  return undefined
109}
110
111async function cancelAsk($: EngineInterface, to: Link, ask: string): Promise<void> {
112  try {
113    await $.http.fetch(`http://127.0.0.1:${to.port}/hooks/ask?id=${encodeURIComponent(ask)}&cancel=1`, {
114      headers: { 'x-claude-code-ide-authorization': to.auth },
115    })
116  } catch {
117    // Unanswered, the ask is pruned once nothing polls it.
118  }
119}
120
121// --- Prompts from maiTerm (claude_code/mod_inbox.rs) --------------------------------------
122//
123// What maiTerm sends this tab's agent between turns (a phone message, a follow-up, an Overlord
124// directive, a peer's message) comes through here rather than typed: one loop long-polls GET
125// /hooks/inbox, acks each prompt (maiTerm refuses the ack for one it has meanwhile given up on
126// and typed instead) and submits it, which starts a turn and leaves the human's draft in the
127// box. Only in an interactive session: a `claude -p` an agent runs inherits this
128// tab's environment, and must not take its prompts.
129
130// The interactive session's id, kept in the environment because module state isn't: a module
131// reloaded mid-session (maiTerm rewrites these files on upgrade) gets no `session.start`, and
132// restarts its loop from the next event once this names the session it belongs to. A child
133// process inherits the variable but runs another session. (`$.env` takes the name spelled out
134// at each call: MAITERM_MOD_SESSION.)
135
136let inboxRunning = false
137
138function startInbox($: EngineInterface, to: Link): void {
139  if (inboxRunning) return
140  inboxRunning = true
141  const base = `http://127.0.0.1:${to.port}/hooks/inbox?tab_id=${encodeURIComponent(to.tab)}`
142  const headers = { 'x-claude-code-ide-authorization': to.auth }
143  const again = (ms: number) => {
144    $.clock.after(ms, () => {
145      void round()
146    })
147  }
148  const round = async (): Promise<void> => {
149    let r
150    try {
151      r = await $.http.fetch(`${base}&wait=10`, { headers })
152    } catch {
153      return again(5000)
154    }
155    if (r.status === 204) return again(1)
156    if (r.status !== 200) return again(5000)
157    let offer: { id?: string; text?: string }
158    try {
159      offer = JSON.parse(r.text)
160    } catch {
161      return again(1)
162    }
163    if (offer.id && typeof offer.text === 'string') {
164      try {
165        const id = offer.id
166        const ack = await $.http.fetch(`${base}&ack=${encodeURIComponent(id)}`, { headers })
167        // Not awaited: it resolves only as the turn starts, and the next prompt shouldn't wait.
168        // maiTerm sends only between turns, but one can start first; until this one's turn has,
169        // it routes the tab's later prompts here too, so a typed one can't overtake it.
170        if (ack.status === 204) {
171          void $.prompt
172            .submit({ text: offer.text, asUser: true })
173            .catch(() => {})
174            .then(() => $.http.fetch(`${base}&started=${encodeURIComponent(id)}`, { headers }))
175            .catch(() => {})
176        }
177      } catch {
178        // Unacked, maiTerm takes it back.
179      }
180    }
181    again(1)
182  }
183  again(1)
184}
185
186async function ensureInbox($: EngineInterface, to: Link): Promise<void> {
187  if (inboxRunning) return
188  const [mine, session] = await Promise.all([$.env.get('MAITERM_MOD_SESSION'), $.session.id()])
189  if (mine && mine === session) startInbox($, to)
190}
191
192// --- Transcript growth, for an ssh host (mailink/mirror.rs) --------------------------------
193//
194// On an ssh host maiTerm reads this session's transcript from a local copy it tops up over the
195// connection, on each hook event — and a long turn sends none until it ends. So the rows the
196// session stores are reported, and maiTerm fetches what was added. Not at once: a stored row
197// reaches the file a beat later (seen on nova, 2.1.295: rows stored at :41–:43, the file grew
198// at :44, so a report at :43 fetched nothing and the turn's text waited 15 s for the next hook).
199// A burst is reported ~1 s after its first row, and again 3 s after that for a slower write. A
200// local tab's transcript is read in place: maiTerm answers 410 and no more are sent.
201
202let appendReports: 'idle' | 'pending' | 'off' = 'idle'
203// Only the first answer can turn the reports off. A local tab says 410 from the start; an ssh tab
204// can be told 410 for a few seconds while its tunnel reconnects (maiTerm re-lists the host's
205// tabs one at a time), and stopping then would end the reports for this whole process.
206let appendAnswered = false
207
208async function reportAppended($: EngineInterface, to: Link): Promise<void> {
209  try {
210    const session = await $.session.id()
211    const r = await $.http.fetch(
212      `http://127.0.0.1:${to.port}/hooks/appended?tab_id=${encodeURIComponent(to.tab)}&session_id=${encodeURIComponent(session)}`,
213      { headers: { 'x-claude-code-ide-authorization': to.auth } },
214    )
215    appendReports = r.status === 410 && !appendAnswered ? 'off' : 'idle'
216    appendAnswered = true
217  } catch {
218    appendReports = 'idle'
219  }
220}
221
222// The events maiTerm's server reads, forwarded as the settings hooks would have sent them.
223// Sent BEFORE `next(e)`: the settings hooks run beneath, so maiTerm hears this copy first
224// and knows to drop theirs.
225async function relay<E, R>($: EngineInterface, e: E, next: (e: E) => Promise<R>): Promise<R> {
226  const to = await linkOf($)
227  if (to) {
228    await send($, to, e)
229    await ensureInbox($, to)
230  }
231  return next(e)
232}
233
234export const register: Register = on => {
235  on('session.start', async ($, e, next) => {
236    const result = await next(e)
237    const to = await linkOf($)
238    if (!to) return result
239    await $.env.set('MAITERM_MOD_SESSION', e.isInteractive ? await $.session.id() : '')
240    if (e.isInteractive) startInbox($, to)
241    return result
242  })
243
244  on('classic.SessionStart', async ($, e, next) => {
245    const to = await linkOf($)
246    if (!to) return next(e)
247    // A /clear starts a new session in this process (and no `session.start`): the loop runs on.
248    if (inboxRunning) await $.env.set('MAITERM_MOD_SESSION', e.session_id)
249    // The server's reply is the session's standing instructions: the tab and session ids and
250    // whatever this tab's features need (the Overlord, tasks, the stack, follow-ups).
251    const priming = await send($, to, e, '&prime=1')
252    const result = await next(e)
253    if (!priming) return result
254    return { ...result, additionalContext: [...(result.additionalContext ?? []), priming] }
255  })
256
257  on('classic.SessionEnd', relay)
258  on('classic.Notification', relay)
259  on('classic.Stop', relay)
260  on('classic.UserPromptSubmit', relay)
261  on('classic.PostToolUse', relay)
262  on('classic.PostToolUseFailure', relay)
263  // Runs while the dialog is on screen (Claude draws it and raises this together). The decision
264  // maiTerm hands back closes the dialog as if its row had been pressed.
265  on('classic.PermissionRequest', async ($, e, next) => {
266    const to = await linkOf($)
267    if (!to) return next(e)
268    // AskUserQuestion's own permission is the selector itself, answered through tool.call below.
269    if (e.tool_name === 'AskUserQuestion') return relay($, e, next)
270    const ask = newAskId()
271    await send($, to, e, `&ask=${encodeURIComponent(ask)}`)
272    const result = await next(e)
273    // A settings hook of the user's own already decided: that stands.
274    if (result.decision) return result
275    const answer = (await awaitAnswer($, to, ask, () => false)) as { decision?: PermissionRequestDecision } | undefined
276    const decision = answer?.decision
277    if (!decision) return result
278    if (decision.behavior === 'deny' && decision.interrupt && !e.agent_id) {
279      // A deny fires no hook, and an interrupted turn sends no Stop, so maiTerm would leave the
280      // tab waiting on a dialog that is gone. The turn has stopped and the agent waits on its
281      // human, which is what idle_prompt tells maiTerm; sent once the dialog has closed. Only
282      // for the main thread's own dialog: a subagent's deny says nothing about whether the
283      // main thread is still working, and idle_prompt would release its calls too.
284      const session_id = e.session_id
285      const cwd = e.cwd
286      $.clock.after(300, () => {
287        void send($, to, { hook_event_name: 'Notification', notification_type: 'idle_prompt', session_id, cwd })
288      })
289    }
290    return { ...result, decision }
291  })
292  on('classic.SubagentStop', relay)
293  on('classic.PreCompact', relay)
294
295  // After the row is stored, never ahead of it: this hook sits in the store path. A subagent's
296  // rows go to its own file, which maiTerm doesn't copy.
297  on('session.append', async ($, e, next) => {
298    const stored = await next(e)
299    if (appendReports !== 'idle' || e.agentId) return stored
300    const to = await linkOf($)
301    if (to) {
302      appendReports = 'pending'
303      // Rows stored meanwhile ride along with this burst's reports.
304      $.clock.after(1000, () => {
305        void reportAppended($, to).then(() => {
306          if (appendReports === 'off') return
307          $.clock.after(3000, () => {
308            if (appendReports === 'idle') void reportAppended($, to)
309          })
310        })
311      })
312    }
313    return stored
314  })
315
316  // The one event whose reply matters: maiTerm allows a model switch it asked for, which
317  // skips Claude's "Switch model?" cache-miss confirm.
318  on('classic.PreModelSwitch', async ($, e, next) => {
319    const to = await linkOf($)
320    if (!to) return next(e)
321    const reply = await send($, to, e)
322    const result = await next(e)
323    try {
324      const decision = reply ? JSON.parse(reply)?.hookSpecificOutput : undefined
325      if (decision?.permissionDecision === 'allow') {
326        return { ...result, permissionDecision: 'allow', permissionDecisionReason: decision.permissionDecisionReason }
327      }
328    } catch {
329      // An empty or non-JSON reply decides nothing.
330    }
331    return result
332  })
333
334  // PreToolUse comes from `tool.call` rather than `classic.PreToolUse`, whose input is the bare
335  // tool call: it lacks the session id and, in a subagent, the agent's id, both of which the
336  // permission ledger keys on (claude_code/gate.rs). This is the same moment, one level up.
337  on('tool.call', async ($, e, next) => {
338    const to = await linkOf($)
339    if (!to) return next(e)
340    const { tool, tool_use_id, agentId, consent: _consent, ...tool_input } = e as Record<string, unknown>
341    const isQuestion = e.tool === 'AskUserQuestion'
342    const ask = isQuestion ? newAskId() : undefined
343    const call = {
344      session_id: await $.session.id(),
345      cwd: await $.session.cwd(),
346      tool_name: tool,
347      tool_input,
348      tool_use_id,
349      ...(agentId ? { agent_id: agentId } : {}),
350    }
351    await send($, to, { hook_event_name: 'PreToolUse', ...call }, ask ? `&ask=${encodeURIComponent(ask)}` : '')
352    if (!ask || e.tool !== 'AskUserQuestion') return next(e)
353
354    // The selector (`next`) and maiTerm race; whichever answers first is the tool's result.
355    type First =
356      | { from: 'desktop'; r: Awaited<ReturnType<typeof next>> }
357      | { from: 'maiterm'; answers: Record<string, string> }
358    let settled = false
359    const local: Promise<First> = next(e).then(r => {
360      settled = true
361      return { from: 'desktop', r }
362    })
363    const remote: Promise<First> = awaitAnswer($, to, ask, () => settled).then(a => {
364      const answers = (a as { answers?: Record<string, string> } | undefined)?.answers
365      // Nothing from maiTerm: the desktop's answer is the only one coming.
366      return answers ? { from: 'maiterm', answers } : local
367    })
368    const first = await Promise.race([local, remote])
369    if (first.from === 'desktop') {
370      void cancelAsk($, to, ask)
371      return first.r
372    }
373    settled = true
374    const answered = { questions: e.questions, answers: first.answers }
375    // Answering with the selector still pending aborts what runs beneath, Claude's PostToolUse
376    // for this call with it, and that event is what tells maiTerm the question is over: without
377    // it the phone kept drawing an answered question until the turn ended. So its end is
378    // reported here.
379    await send($, to, { hook_event_name: 'PostToolUse', ...call, tool_response: answered })
380    return { result: answered }
381  })
382
383  // An agent in a maiTerm tab talks to OTHER agents only where its human said it may: over the
384  // mesh (sendToBridgedAgent), whose roster is the workspaces the human linked. Claude's own
385  // SendMessage also reaches every other Claude session on the machine (ListAgents), which
386  // bypassed that entirely — maiMarketing and maiSoft agents traded messages with no link
387  // between their workspaces, unseen by maiTerm. So a send to ANOTHER SESSION is refused, and the
388  // model reads the reason as the tool's result. A plugin sending on the tab's behalf is held to
389  // the same rule: whoever composed it, it would still be this tab talking past its mesh.
390  //
391  // Refused only on positive evidence that the recipient is a session (`isOtherSession`), never
392  // because it is missing from the session's own agents: that list forgets a finished subagent
393  // that SendMessage can still resume, and never holds the lead a teammate answers as
394  // `team-lead` (review of 76421ae0). A session this misses is still stopped where it lands —
395  // `crossSessionInbound: refuse` on the receiving end.
396  on('tool.call', { tool: 'ListAgents' }, async ($, e, next) => {
397    const r = await next(e)
398    if (await linkOf($)) recordSessions((r as { result?: unknown }).result)
399    return r
400  })
401  on('session.send', async ($, e, next) => {
402    if (!(await linkOf($)) || !isOtherSession(e.to) || (await isOwnAgent($, e.to))) return next(e)
403    return {
404      isDelivered: false,
405      reason:
406        `maiTerm: "${e.to}" is another Claude session, and messaging other sessions is off in maiTerm tabs — ` +
407        'your human decides which agents talk to each other. To reach another agent use sendToBridgedAgent ' +
408        '(listBridgedPeers shows who you can reach); if the one you need is not there, ask your human to link ' +
409        'its workspace to yours. SendMessage still works for your own subagents.',
410    }
411  })
412}
413
414// Names ListAgents filed under another session (its own machine's, a cloud one, a Remote Control
415// one) during this module's life. The listing is the only way a model learns a session's name, so
416// a name it can address is a name this saw. Lost on a reload (a deploy rewrites the mod), when the
417// model has to list again before it can name one — and the inbound setting covers the gap.
418const sessionNames = new Set<string>()
419const SESSION_KIND = /session|cloud|remote|peer/i
420
421function recordSessions(result: unknown) {
422  const sections = (result as { sections?: { kind?: unknown; rows?: { name?: unknown; ref?: unknown; id?: unknown }[] }[] } | undefined)?.sections
423  if (!Array.isArray(sections)) return
424  for (const s of sections) {
425    if (typeof s.kind !== 'string' || !SESSION_KIND.test(s.kind) || /agent|teammate/i.test(s.kind)) continue
426    for (const row of s.rows ?? []) {
427      for (const v of [row.name, row.id]) if (typeof v === 'string' && v) sessionNames.add(v)
428      if (typeof row.name === 'string' && typeof row.ref === 'string') sessionNames.add(`${row.name} [${row.ref}]`)
429    }
430  }
431}
432
433// An address SendMessage only gives a session — `uds:<socket>` and `bridge:<session id>`, the
434// forms its schema documents for one — or a name ListAgents listed as a session.
435function isOtherSession(to: string): boolean {
436  if (/^(uds|bridge):/.test(to)) return true
437  return sessionNames.has(to) || sessionNames.has(to.replace(/\s*\[[^\]]*\]$/, ''))
438}
439
440// Is `to` this session's own subagent or teammate (or, from inside one, its parent)? Checked
441// first so an own agent that happens to share a listed session's name is still reached.
442async function isOwnAgent($: EngineInterface, to: string): Promise<boolean> {
443  if (to === 'main') return true
444  const agents = await $.agent.list().catch(() => [])
445  return agents.some(a =>
446    a.id === to || a.name === to || a.teammateId === to || (!!a.teammateId && a.teammateId.split('@')[0] === to),
447  )
448}
449