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
c697e8c
fix(terminal-core): preserve cache fields dropped on every remount
tamtranthien Aug 15, 2026
1734f0f
merge: preserve terminal cache fields dropped on every remount
tamtranthien Aug 15, 2026
c81b167
refactor(identity): split Terminal.tab_id into renderer_terminal_id +…
tamtranthien Aug 15, 2026
e4a1017
feat(api): expose owningTabId on every terminal identity response
tamtranthien Aug 15, 2026
2034475
feat(api): emit owningTabId on terminal:external-activity
tamtranthien Aug 15, 2026
44c15b6
feat(api): mint a distinct tm- leaf for every pane added to a live tab
tamtranthien Aug 15, 2026
27faf0f
refactor(pty): spawn_terminal carries the leaf and the owning tab
tamtranthien Aug 15, 2026
f468eb6
fix(api): stop two API splits in one tab colliding on the history key
tamtranthien Aug 15, 2026
320c8d7
feat(sidecar): thread owning_tab_id through the PTY-host spawn path
tamtranthien Aug 15, 2026
9c63dc5
fix(fleet): register both renderer ids at spawn instead of patching a…
tamtranthien Aug 15, 2026
4ba5b67
fix(history): never key persisted scrollback by a PTY process id
tamtranthien Aug 15, 2026
775062d
feat(renderer): send the owning tab id when spawning a terminal
tamtranthien Aug 15, 2026
12558c4
fix(renderer): light the owning tab when an API call hits a split pane
tamtranthien Aug 15, 2026
88f4911
fix(renderer): reconcile terminals by leaf id so splits are not reaped
tamtranthien Aug 15, 2026
f3f63b0
fix(renderer): bind an API split to its minted leaf, not to its proce…
tamtranthien Aug 15, 2026
0069e02
fix(renderer): remap terminalCwds when sanitisation rewrites a leaf id
tamtranthien Aug 15, 2026
a3aa836
docs(mcp): describe the three terminal id spaces and expose owningTabId
tamtranthien Aug 15, 2026
6941b4c
fix(api): reserve the owner across a create's spawn to close the root…
tamtranthien Aug 15, 2026
e94e5b1
fix(p0a): keep the backend owning tab in step with pane moves (review…
tamtranthien Aug 15, 2026
ce8cf1b
fix(p0a): correct legacy leaf fallback and widen ElectronAPI createTe…
tamtranthien Aug 15, 2026
a153044
fix(api): reserve the root leaf on the renderer create path too (revi…
tamtranthien Aug 15, 2026
ecbeee0
fix(panes): re-assert a pane's owner once its spawn registers (review…
tamtranthien Aug 15, 2026
20bbe6e
ci: stop setup-bun fighting the in-use bun.exe on the self-hosted runner
tamtranthien Aug 15, 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
22 changes: 20 additions & 2 deletions .github/workflows/e2e-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,21 @@ jobs:
with:
node-version: '18'

- name: Setup Bun
# This job runs on the self-hosted Windows box, where bun is already
# installed and usually already RUNNING — oven-sh/setup-bun then fails
# trying to overwrite the in-use bun.exe with `EBUSY: resource busy or
# locked`, which surfaces as a random red X on an otherwise green PR.
# rust-tests.yml has carried this workaround for a while; e2e-tests.yml
# was missed. Same two-step shape, so the jobs stay comparable and a
# future move to a GitHub-hosted runner still installs bun.
- name: Setup Bun (GitHub-hosted)
if: runner.os != 'Windows'
uses: oven-sh/setup-bun@v2

- name: Verify Bun (Windows)
if: runner.os == 'Windows'
run: bun --version

- name: Install dependencies
run: bun install --frozen-lockfile

Expand Down Expand Up @@ -95,9 +107,15 @@ jobs:
with:
node-version: '18'

- name: Setup Bun
# Same EBUSY workaround as the e2e job above — see the comment there.
- name: Setup Bun (GitHub-hosted)
if: runner.os != 'Windows'
uses: oven-sh/setup-bun@v2

- name: Verify Bun (Windows)
if: runner.os == 'Windows'
run: bun --version

- name: Install dependencies
run: bun install --frozen-lockfile

Expand Down
33 changes: 24 additions & 9 deletions mcp-server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export function createMcpServer({ api, getCallerId }: McpServerDeps): McpServer
server.registerTool(
"list_terminals",
{
description: "List active terminal sessions across the fleet. Each entry includes machineId, os, and deviceName; local terminals are tagged with this machine.",
description: "List active terminal sessions across the fleet. Each entry includes machineId, os, and deviceName; local terminals are tagged with this machine. Each entry also carries `terminalId` (the renderer pane) and `owningTabId` (its tab).",
},
async () => {
try {
Expand All @@ -56,26 +56,39 @@ export function createMcpServer({ api, getCallerId }: McpServerDeps): McpServer
server.registerTool(
"create_terminal",
{
description: "Spawn a new terminal process (supports split panel layout)",
description:
"Spawn a new terminal process (supports split panel layout). Terminal ids come " +
"in three flavours and are NOT interchangeable: `terminalId`/`processId` (`pc-…`) " +
"addresses the PTY for every other tool; `owningTabId` (`tb-…`) names a TAB; and " +
"the `terminalId` field of a terminal-detail response is the renderer PANE " +
"(`tb-…` for a solo pane, `tm-…` for a split).",
inputSchema: {
name: z.string().optional().describe("Name of the terminal session"),
profile: z.string().optional().describe("Shell profile ID (e.g., 'powershell', 'cmd', 'git-bash'). Defaults to system default."),
cols: z.number().optional().default(120),
rows: z.number().optional().default(40),
cwd: z.string().optional().describe("Current working directory"),
tabId: z.string().optional().describe("Tab ID where the terminal pane should be created/split"),
owningTabId: z.string().optional().describe(
"The TAB (`tb-…`) the new pane should belong to — read it from " +
"get_terminal_detail's `owningTabId`. Preferred over `tabId`."
),
tabId: z.string().optional().describe(
"DEPRECATED alias of owningTabId. Must be a TAB id (`tb-…`); passing a " +
"pane id (`tm-…`) is rejected with 400 — use owningTabId instead."
),
paneId: z.string().optional().describe("Pane ID within the tab to split"),
direction: z.enum(["horizontal", "vertical"]).optional().describe("Split direction: 'horizontal' (split right) or 'vertical' (split bottom)"),
},
},
async ({ name, profile, cols, rows, cwd, tabId, paneId, direction }) => {
async ({ name, profile, cols, rows, cwd, owningTabId, tabId, paneId, direction }) => {
try {
const response = await api.post(`/terminals`, {
name,
profile_id: profile,
cols,
rows,
cwd,
owningTabId,
tabId,
paneId,
direction,
Expand Down Expand Up @@ -223,7 +236,7 @@ export function createMcpServer({ api, getCallerId }: McpServerDeps): McpServer
server.registerTool(
"get_terminal_detail",
{
description: "Get detailed information about a specific terminal session (including its tabId)",
description: "Get detailed information about a specific terminal session, including its renderer pane id (`terminalId`) and the tab that owns it (`owningTabId`). `tabId` is a deprecated alias of `terminalId` and is NOT a tab id for a split pane.",
inputSchema: {
terminalId: z.string().describe(`The ID of the terminal session to retrieve. ${ME_HINT}`),
},
Expand All @@ -245,10 +258,12 @@ export function createMcpServer({ api, getCallerId }: McpServerDeps): McpServer
"get_my_terminal",
{
description:
"Get YOUR OWN terminal's identity and details (id, pid, tabId, name) — the terminal " +
"this agent is running in. Resolved from the X-Termflow-Terminal-Id header (mapped " +
"from the $TERMFLOW_TERMINAL_ID env var injected into every terminal). Use the returned " +
'id, or the "me" shorthand, to target your own terminal with the other tools.',
"Get YOUR OWN terminal's identity and details (id, pid, terminalId, owningTabId, name) " +
"— the terminal this agent is running in. Resolved from the X-Termflow-Terminal-Id " +
"header (mapped from the $TERMFLOW_TERMINAL_ID env var injected into every terminal). " +
"The response carries `terminalId` (this pane) and `owningTabId` (the tab it lives in); " +
"pass the latter to create_terminal to open a sibling pane in the same tab. Use the " +
'returned id, or the "me" shorthand, to target your own terminal with the other tools.',
},
async () => {
try {
Expand Down
39 changes: 39 additions & 0 deletions mcp-server/test/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,45 @@ describe("list_machines tool", () => {
});
});

describe("terminal identity is described unambiguously to AI clients", () => {
async function toolMap(client: Client) {
const { tools } = await client.listTools();
return new Map(tools.map((t: any) => [t.name, t]));
}

// Ground-truth correction C5: `tabId` was described as "Tab ID where the
// terminal pane should be created/split", but for a split the backend
// returns a `tm-` LEAF under that key — so an agent round-tripping it
// created the pane in the wrong tab (and, before P0-A, silently got a brand
// new unrelated tab).
it("create_terminal steers the caller to owningTabId", async () => {
const { api } = makeFakeApi();
const client = await connectClient(createMcpServer({ api, getCallerId: () => "pc-self" }));
const tool: any = (await toolMap(client)).get("create_terminal");
const described = JSON.stringify(tool.inputSchema);
expect(described).toContain("owningTabId");
});

it("create_terminal forwards owningTabId to the backend", async () => {
const { api, calls } = makeFakeApi();
const client = await connectClient(createMcpServer({ api, getCallerId: () => "pc-self" }));
await client.callTool({
name: "create_terminal",
arguments: { owningTabId: "tb-4e8d0c2f1", paneId: "pn-a", direction: "vertical" },
});
const post = calls.find((c) => c.method === "post" && c.url === "/terminals");
expect((post?.body as any)?.owningTabId).toBe("tb-4e8d0c2f1");
});

it("get_terminal_detail and get_my_terminal advertise owningTabId", async () => {
const { api } = makeFakeApi();
const client = await connectClient(createMcpServer({ api, getCallerId: () => "pc-self" }));
const tools = await toolMap(client);
expect(tools.get("get_terminal_detail")!.description).toContain("owningTabId");
expect(tools.get("get_my_terminal")!.description).toContain("owningTabId");
});
});

describe("get_terminal_screen tool", () => {
it("posts { terminalId } to /fleet/screen for a local screen", async () => {
const { api, calls } = makeFakeApi();
Expand Down
8 changes: 8 additions & 0 deletions packages/terminal-core/src/TerminalEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1726,6 +1726,14 @@ export class TerminalEngine {
// that order IS the LRU order enforceCacheCap() evicts from.
terminalCache.delete(this.cacheKey);
terminalCache.set(this.cacheKey, {
// Spread the existing entry FIRST so any field TerminalCacheEntry declares
// that isn't explicitly re-listed below survives this rebuild by default.
// Without this, agentColorLocked/lastSnapshot/lastDataAt/lastInputAt were
// silently dropped on every remount (never listed here even though the
// type declares them) — see terminal-cache-drops-fields-on-mount. Explicit
// keys AFTER the spread still win where this rebuild must overwrite/reset
// a field (terminal, fitAddon, disposables, kbState, win32State, ...).
...existingCache,
terminal: term,
processId: existingCache?.processId,
fitAddon: fit,
Expand Down
36 changes: 36 additions & 0 deletions packages/terminal-core/src/__tests__/cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,42 @@ it('never evicts an entry whose element is still in the DOM', () => {
expect(terminalCache.size).toBeLessThanOrEqual(MAX_TERMINAL_CACHE_ENTRIES);
});

// --- mount()-end cache rebuild must not drop fields it doesn't explicitly list -----
//
// TerminalEngine's mount()-end rebuild (the delete-then-set that reorders the Map key
// for LRU) used to copy the cache entry field-by-field into a fresh object literal.
// Any TerminalCacheEntry field NOT named in that literal was silently dropped on every
// remount. agentColorLocked/lastSnapshot/lastDataAt/lastInputAt were the four fields
// missing from the literal (see terminal-cache-drops-fields-on-mount memory note).

it('a remount preserves agentColorLocked, lastSnapshot, lastDataAt and lastInputAt', () => {
const cacheKey = 'field-preserve';

const engine1 = new TerminalEngine(makeFakeBridge(), { cacheKey });
engine1.mount(makeContainer());

const beforeRemount = terminalCache.get(cacheKey)!;
beforeRemount.agentColorLocked = true;
beforeRemount.lastSnapshot = 'snapshot-marker';
beforeRemount.lastDataAt = 111;
beforeRemount.lastInputAt = 222;

engine1.unmount();

// A fresh engine on the SAME cacheKey (e.g. a tab switch) takes the reattach
// path, which ends in the delete-then-set rebuild under test.
const engine2 = new TerminalEngine(makeFakeBridge(), { cacheKey });
engine2.mount(makeContainer());

const afterRemount = terminalCache.get(cacheKey)!;
expect(afterRemount.agentColorLocked).toBe(true);
expect(afterRemount.lastSnapshot).toBe('snapshot-marker');
expect(afterRemount.lastDataAt).toBe(111);
expect(afterRemount.lastInputAt).toBe(222);

engine2.unmount();
});

// --- refreshGlyphAtlases (standby/resume blank-text repair) ---------------------

function webglEntry(onClear: () => void) {
Expand Down
Loading
Loading