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

<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:
maiTerm is fully written by AI (Claude), with human engineering direction and architectural rails.
Runs on macOS, Windows, and Linux.
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.
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.

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.
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.

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.

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.
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.
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.
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.
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.
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.
l command wraps ls to emit clickable file links; underline appears on hoverl shell function — always available in local shells; also injectable into remote shells via context menu~/.bashrc/~/.zshrc installationCmd+O — file dialog that defaults to the active terminal's CWD.bashrc, Dockerfile), and shebang lineCmd+F, positioned at top of editorCmd+S writes local or remote via SCP; dirty indicator in tabwindow.confirm) for unsaved changesgetClaudeSessions exposes all active Claude sessions (state, tool, model, cwd) across tabssendToBridgedAgent / getBridgedAgent); fork-into-split or link existing tabs, async messaging with maiTerm-stamped identities, persists across restarts-R 0:localhost:port) exposes local IDE tools to remote Claude Code; ControlMaster mux support, bridge status indicator~/.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 SSHaiterm-dev with display name "maiTermDev"notify (toast or OS notification), send_command (write to PTY), enable_auto_resume, set_tab_state%varName), persisted per tab&&, ||, !, ==, != operatorsauto (in-app when focused, OS when not), in_app, native, disabled| Shortcut | Action |
|---|---|
Cmd+T | New tab |
Cmd+W | Close tab (or pane if last tab) |
Cmd+1–9 | Switch to tab |
Cmd+Shift+[ | Previous tab |
Cmd+Shift+] | Next tab |
Cmd+Shift+T | Duplicate tab |
Cmd+Shift+R | Reload tab (duplicate + close) |
Cmd+D | Split pane (duplicate tab) |
Cmd+N | New workspace |
Cmd+O | Open file in editor tab |
Cmd+S | Save file (editor tabs) |
Cmd+F | Find/replace (editor tabs) |
Cmd+, | Preferences |
Cmd+/ | Help |
Cmd+Shift+N | Notes panel toggle |
All platforms require:
xcode-select --install)# 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
npm run tauri:build
Build output by platform:
| Platform | Format | Output path |
|---|---|---|
| macOS | DMG | src-tauri/target/release/bundle/dmg/ |
| Windows | NSIS installer | src-tauri/target/release/bundle/nsis/ |
| Linux | .deb | src-tauri/target/release/bundle/deb/ |
After building on macOS, set the DMG volume icon:
./scripts/set-dmg-icon.sh
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 installersrc/ # 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
| Layer | Technology |
|---|---|
| Frontend | Svelte 5 (runes), SvelteKit, TypeScript |
| Backend | Rust, Tauri 2 |
| Terminal | alacritty_terminal (Rust VTE parser + buffer) with xterm.js as thin DOM renderer |
| Editor | CodeMirror 6 (+ MergeView for diffs) |
| PTY | portable-pty |
| Scrollback | SQLite (WAL mode) via rusqlite |
| State | parking_lot RwLock |
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
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 */
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:
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.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/.
hooks/register.ts 449 lines1import 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