feat(control-ui): reattach terminal sessions after reload/reconnect with replay

This commit is contained in:
Peter Steinberger
2026-07-04 12:55:46 -07:00
parent 5938c7809a
commit b33f09b40d
44 changed files with 512 additions and 188 deletions
@@ -314,6 +314,83 @@ describe("TerminalConnection", () => {
expect(conn.size).toBe(2);
});
it("attach replays the buffer before events that raced ahead, then resumes live", async () => {
const client = makeFakeClient();
const conn = new TerminalConnection(client);
const data: string[] = [];
let resolveAttach: (() => void) | undefined;
client.request = ((method: string, params: unknown) => {
client.requests.push({ method, params });
if (method === "terminal.attach") {
return new Promise<unknown>((resolve) => {
resolveAttach = () =>
resolve({
sessionId: "s1",
agentId: "main",
shell: "/bin/zsh",
cwd: "/work",
confined: false,
buffer: "replayed history",
});
});
}
return Promise.resolve({});
}) as typeof client.request;
const attachPromise = conn.attach("s1", {
onData: (d) => data.push(d),
onExit: () => {},
});
// Post-snapshot bytes the server emits between rebind and the response.
client.emit("terminal.data", { sessionId: "s1", seq: 5, data: " tail" });
expect(data).toEqual([]);
resolveAttach?.();
const result = await attachPromise;
expect(result.buffer).toBe("replayed history");
expect(client.requests[0]).toEqual({
method: "terminal.attach",
params: { sessionId: "s1" },
});
// Buffer first, then the raced event, then live data.
client.emit("terminal.data", { sessionId: "s1", seq: 6, data: " live" });
expect(data).toEqual(["replayed history", " tail", " live"]);
});
it("drops the listener when an attach fails so failures do not leak subscriptions", async () => {
const client = makeFakeClient();
const conn = new TerminalConnection(client);
client.request = ((method: string, params: unknown) => {
client.requests.push({ method, params });
// Expired/unknown session after the detach grace period.
return Promise.reject(new Error("unknown terminal session"));
}) as typeof client.request;
await expect(conn.attach("gone", { onData: () => {}, onExit: () => {} })).rejects.toThrow(
"unknown terminal session",
);
expect(conn.size).toBe(0);
expect(client.listenerCount()).toBe(0);
});
it("lists attachable sessions and tolerates a missing sessions field", async () => {
const client = makeFakeClient();
const conn = new TerminalConnection(client);
const info = {
sessionId: "s1",
agentId: "main",
shell: "/bin/zsh",
cwd: "/work",
confined: false,
attached: false,
createdAtMs: 1,
};
client.nextResponse = { sessions: [info] };
expect(await conn.list()).toEqual([info]);
client.nextResponse = {};
expect(await conn.list()).toEqual([]);
});
it("dispose() drops the gateway subscription and clears buffered state", async () => {
const client = makeFakeClient();
const conn = new TerminalConnection(client);
@@ -16,6 +16,21 @@ export type TerminalOpenResult = {
confined: boolean;
};
export type TerminalAttachResult = TerminalOpenResult & {
/** Recent output replayed into the emulator before live data resumes. */
buffer: string;
};
export type TerminalSessionInfo = {
sessionId: string;
agentId: string;
shell: string;
cwd: string;
confined: boolean;
attached: boolean;
createdAtMs: number;
};
export type TerminalExitInfo = {
exitCode: number | null;
signal: number | null;
@@ -106,38 +121,73 @@ export class TerminalConnection {
params: { agentId?: string; cols: number; rows: number },
sink: SessionSink,
): Promise<TerminalOpenResult> {
const result = await this.requestWhileHoldingStream(() =>
this.client.request<TerminalOpenResult>("terminal.open", params),
);
this.adoptSession(result.sessionId, sink);
return result;
}
/**
* Rebinds an existing (usually detached) session to this connection and
* replays its buffered output into the sink before live data resumes.
*/
async attach(sessionId: string, sink: SessionSink): Promise<TerminalAttachResult> {
const result = await this.requestWhileHoldingStream(() =>
this.client.request<TerminalAttachResult>("terminal.attach", { sessionId }),
);
this.adoptSession(sessionId, sink, result.buffer);
return result;
}
/** Sessions this operator could attach; empty when the surface is off. */
async list(): Promise<TerminalSessionInfo[]> {
const result = await this.client.request<{ sessions?: TerminalSessionInfo[] }>("terminal.list");
return result?.sessions ?? [];
}
/**
* Holds the event subscription while an open/attach RPC is in flight so a
* concurrent close/exit on another session cannot drop the listener and lose
* this session's early output.
*/
private async requestWhileHoldingStream<T>(run: () => Promise<T>): Promise<T> {
this.ensureSubscribed();
// Holds the subscription while the RPC is in flight so a concurrent
// close/exit on another session cannot drop the listener and lose this
// session's early output.
this.pendingOpenCount += 1;
let result: TerminalOpenResult;
try {
result = await this.client.request<TerminalOpenResult>("terminal.open", params);
const result = await run();
this.pendingOpenCount -= 1;
return result;
} catch (err) {
// A rejected open (sandboxed agent, disabled terminal, missing PTY,
// disconnect race) never registers a sink. Drop the listener when no
// sessions remain so repeated failed opens across reconnects don't
// A rejected open/attach (sandboxed agent, disabled terminal, expired
// session, disconnect race) never registers a sink. Drop the listener
// when no sessions remain so repeated failures across reconnects don't
// accumulate listeners on the shared gateway client.
this.pendingOpenCount -= 1;
this.maybeUnsubscribe();
throw err;
}
this.pendingOpenCount -= 1;
this.sinks.set(result.sessionId, sink);
}
/** Registers a sink, replaying the attach buffer first, then early events. */
private adoptSession(sessionId: string, sink: SessionSink, replay?: string): void {
this.sinks.set(sessionId, sink);
if (replay) {
sink.onData(replay);
}
// Replay any events that raced ahead of registration, in arrival order.
const early = this.pending.get(result.sessionId);
// These are post-snapshot bytes, so they follow the attach replay.
const early = this.pending.get(sessionId);
if (early) {
this.pending.delete(result.sessionId);
this.pending.delete(sessionId);
for (const event of early) {
if (event.kind === "data") {
sink.onData(event.data);
} else {
this.deliverExit(result.sessionId, sink, event.info);
this.deliverExit(sessionId, sink, event.info);
}
}
}
return result;
}
/**
+230 -94
View File
@@ -55,6 +55,12 @@ function shellBasename(shell: string): string {
}
const LAYOUT_KEY = "openclaw.terminal.panel.v1";
// Session ids for reattach after a reload/reconnect. Deliberately
// sessionStorage, not localStorage: attach is take-over, and a shared
// per-origin key would make multiple Control UI windows clobber each other's
// ids and steal each other's live shells. Per-tab storage survives exactly the
// cases reattach is for (reload, laptop sleep, transient disconnect).
const SESSIONS_KEY = "openclaw.terminal.sessions.v1";
const DEFAULT_LAYOUT: PanelLayout = { open: false, dock: "bottom", height: 320, width: 520 };
const MIN_HEIGHT = 140;
const MIN_WIDTH = 320;
@@ -95,6 +101,21 @@ function clampSize(value: unknown, min: number, max: number, fallback: number):
return Math.min(size, max);
}
function loadPersistedSessionIds(): string[] {
try {
const raw = globalThis.sessionStorage?.getItem(SESSIONS_KEY);
if (!raw) {
return [];
}
const parsed: unknown = JSON.parse(raw);
return Array.isArray(parsed)
? parsed.filter((id): id is string => typeof id === "string" && id.length > 0)
: [];
} catch {
return [];
}
}
/** `<openclaw-terminal-panel>` — the dockable Control UI shell surface. */
export class OpenClawTerminalPanel extends LitElement {
/** Gateway client used for terminal.* RPCs; null until connected. */
@@ -145,7 +166,7 @@ export class OpenClawTerminalPanel extends LitElement {
window.addEventListener(TOGGLE_EVENT, this.onToggleRequest);
window.addEventListener("resize", this.onViewportResize);
if (this.open) {
void this.ensureInitialSession();
void this.restoreSessions();
}
}
@@ -163,19 +184,20 @@ export class OpenClawTerminalPanel extends LitElement {
override updated(changed: Map<string, unknown>): void {
if (changed.has("available")) {
if (!this.available) {
// The surface disappeared (gateway disconnect/disable). The server kills
// every PTY on disconnect, so tear down local tabs and the connection
// (disposeAllTabs drops the gateway subscription too) — otherwise a
// reopen after reconnect would show a dead session and skip creating a
// fresh one.
// The surface disappeared (gateway disconnect/disable). Tear down local
// tabs and the connection (disposeAllTabs drops the gateway
// subscription too). Server sessions survive a disconnect for the
// detach grace period, and their ids stay persisted, so the restore on
// reconnect reattaches them instead of opening fresh shells.
if (this.open) {
this.closePanel();
}
this.disposeAllTabs();
} else if (!this.open && loadLayout().open) {
// Hello arrived after mount; restore the persisted open state.
// Hello arrived after mount (or a reconnect); restore the persisted
// open state and reattach persisted sessions where possible.
this.open = true;
void this.ensureInitialSession();
void this.restoreSessions();
}
}
if (changed.has("themeMode")) {
@@ -234,7 +256,7 @@ export class OpenClawTerminalPanel extends LitElement {
} else {
this.open = true;
this.persistLayout();
void this.ensureInitialSession();
void this.restoreSessions();
}
}
@@ -251,12 +273,147 @@ export class OpenClawTerminalPanel extends LitElement {
}
}
/**
* Entry point whenever the panel (re)opens: reattach persisted sessions if
* the gateway still has them, otherwise fall back to one fresh session.
*/
private async restoreSessions(): Promise<void> {
if (!this.client || !this.available || this.booting || this.tabs.length > 0) {
await this.ensureInitialSession();
return;
}
const persisted = loadPersistedSessionIds();
if (persisted.length > 0) {
this.booting = true;
try {
if (!this.connection) {
this.connection = new TerminalConnection(this.client);
}
const listed = await this.connection.list();
const known = new Set(listed.map((session) => session.sessionId));
for (const sessionId of persisted.filter((id) => known.has(id))) {
await this.attachSession(sessionId);
}
} catch {
// terminal.list failed (older gateway, surface flapping): fall through
// to a fresh session below.
} finally {
this.booting = false;
}
// Prune ids the gateway no longer knows (reaped or externally closed).
this.persistLiveSessions();
}
await this.ensureInitialSession();
}
private async ensureInitialSession(): Promise<void> {
if (this.tabs.length === 0 && !this.booting) {
await this.openSession();
}
}
/** Boots a tab with a ghostty terminal, ready for an open or attach RPC. */
private async bootTab(): Promise<{
tab: TerminalTabState;
connection: TerminalConnection;
cols: number;
rows: number;
}> {
if (!this.client) {
throw new Error("terminal client unavailable");
}
const { init, Terminal, FitAddon } = await import("ghostty-web");
await init();
if (!this.connection) {
this.connection = new TerminalConnection(this.client);
}
// Captured so the cancelled-open cleanup can close the session even if a
// teardown swaps this.connection while the open/attach RPC is in flight.
const connection = this.connection;
const host = document.createElement("div");
host.className = "tp-host";
const term = new Terminal({
fontSize: 13,
fontFamily:
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
cursorBlink: true,
theme: terminalTheme(this.themeMode),
scrollback: 5000,
});
const fit = new FitAddon();
term.loadAddon(fit);
const id = `tab-${++this.tabSeq}`;
const tab: TerminalTabState = {
id,
gatewaySessionId: "",
shellName: t("terminal.tabLabel", { n: String(this.tabSeq) }),
hint: "",
term,
fit,
host,
status: "live",
};
this.tabs = [...this.tabs, tab];
this.activeId = id;
// Wait for the panel (and its .tp-viewport) to render before attaching the
// ghostty host, so the terminal opens into a laid-out, measurable node.
await this.updateComplete;
const viewport = this.renderRoot.querySelector(".tp-viewport");
if (!viewport) {
throw new Error("terminal viewport unavailable");
}
viewport.append(host);
term.open(host);
fit.fit();
return { tab, connection, cols: term.cols || 80, rows: term.rows || 24 };
}
/** Output/exit sink for one tab, shared by open and attach. */
private tabSink(tab: TerminalTabState) {
return {
// The cancelled guard also protects the buffered-event replay inside
// connection.open/attach from writing to an already-disposed terminal.
onData: (data: string) => {
if (!tab.cancelled) {
tab.term.write(data);
}
},
onExit: (info: { reason?: string; exitCode: number | null }) => this.handleExit(tab.id, info),
};
}
/** Binds a freshly opened or attached gateway session to its tab. */
private adoptSession(
tab: TerminalTabState,
result: { sessionId: string; shell: string; agentId: string; cwd: string },
): void {
tab.gatewaySessionId = result.sessionId;
tab.shellName = shellBasename(result.shell);
tab.hint = t("terminal.tabHint", { agent: result.agentId, cwd: result.cwd });
// Forward keystrokes and viewport resizes to the PTY.
tab.term.onData((data) => void this.connection?.input(result.sessionId, data));
tab.term.onResize(
({ cols: c, rows: r }) => void this.connection?.resize(result.sessionId, c, r),
);
tab.fit.observeResize();
this.tabs = [...this.tabs];
this.persistLiveSessions();
}
/** Removes a tab whose open/attach never produced a server session. */
private dropFailedTab(tab: TerminalTabState): void {
this.disposeTab(tab);
this.tabs = this.tabs.filter((entry) => entry.id !== tab.id);
if (this.activeId === tab.id) {
this.activeId = this.tabs.at(-1)?.id ?? null;
}
}
private async openSession(): Promise<void> {
if (!this.client || !this.available || this.booting) {
return;
@@ -268,118 +425,80 @@ export class OpenClawTerminalPanel extends LitElement {
// Tracked outside the try so the catch can dispose a tab whose open failed.
let createdTab: TerminalTabState | undefined;
try {
const { init, Terminal, FitAddon } = await import("ghostty-web");
await init();
if (!this.connection) {
this.connection = new TerminalConnection(this.client);
}
// Captured so the cancelled-open cleanup below can close the session even
// if a teardown swaps this.connection while the open RPC is in flight.
const connection = this.connection;
const host = document.createElement("div");
host.className = "tp-host";
const term = new Terminal({
fontSize: 13,
fontFamily:
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
cursorBlink: true,
theme: terminalTheme(this.themeMode),
scrollback: 5000,
});
const fit = new FitAddon();
term.loadAddon(fit);
const id = `tab-${++this.tabSeq}`;
const tab: TerminalTabState = {
id,
gatewaySessionId: "",
shellName: t("terminal.tabLabel", { n: String(this.tabSeq) }),
hint: "",
term,
fit,
host,
status: "live",
};
createdTab = tab;
this.tabs = [...this.tabs, tab];
this.activeId = id;
// Wait for the panel (and its .tp-viewport) to render before attaching the
// ghostty host, so the terminal opens into a laid-out, measurable node.
await this.updateComplete;
const viewport = this.renderRoot.querySelector(".tp-viewport");
if (!viewport) {
throw new Error("terminal viewport unavailable");
}
viewport.append(host);
term.open(host);
fit.fit();
const cols = term.cols || 80;
const rows = term.rows || 24;
const result = await connection.open(
{ agentId, cols, rows },
{
// The cancelled guard also protects the buffered-event replay inside
// connection.open from writing to an already-disposed terminal.
onData: (data) => {
if (!tab.cancelled) {
term.write(data);
}
},
onExit: (info) => this.handleExit(id, info),
},
const boot = await this.bootTab();
createdTab = boot.tab;
const result = await boot.connection.open(
{ agentId, cols: boot.cols, rows: boot.rows },
this.tabSink(boot.tab),
);
if (tab.cancelled) {
if (boot.tab.cancelled) {
// The tab's close button was clicked while the open RPC was in flight.
// The server session is live and its sink registered; close it now or
// it survives invisibly (eating the session cap) until disconnect.
void connection.close(result.sessionId);
void boot.connection.close(result.sessionId);
return;
}
tab.gatewaySessionId = result.sessionId;
tab.shellName = shellBasename(result.shell);
tab.hint = t("terminal.tabHint", { agent: result.agentId, cwd: result.cwd });
// Forward keystrokes and viewport resizes to the PTY.
term.onData((data) => void this.connection?.input(result.sessionId, data));
term.onResize(({ cols: c, rows: r }) => void this.connection?.resize(result.sessionId, c, r));
fit.observeResize();
this.tabs = [...this.tabs];
term.focus();
this.adoptSession(boot.tab, result);
boot.tab.term.focus();
} catch (err) {
this.errorText = err instanceof Error ? err.message : String(err);
// A failed open (e.g. terminal disabled or a sandboxed agent is refused)
// must not leave a phantom "live" tab with no server session. Drop it but
// keep the panel open so the error stays visible.
if (createdTab && !createdTab.gatewaySessionId) {
const dead = createdTab;
this.disposeTab(dead);
this.tabs = this.tabs.filter((entry) => entry.id !== dead.id);
if (this.activeId === dead.id) {
this.activeId = this.tabs.at(-1)?.id ?? null;
}
this.dropFailedTab(createdTab);
}
} finally {
this.booting = false;
}
}
/** Reattaches one persisted session; returns false when it is gone. */
private async attachSession(sessionId: string): Promise<boolean> {
let createdTab: TerminalTabState | undefined;
try {
const boot = await this.bootTab();
createdTab = boot.tab;
const result = await boot.connection.attach(sessionId, this.tabSink(boot.tab));
if (boot.tab.cancelled) {
void boot.connection.close(result.sessionId);
return false;
}
this.adoptSession(boot.tab, result);
// The PTY grid rarely matches the new viewport; resizing also delivers
// the SIGWINCH repaint that restores full-screen apps after the replay.
void boot.connection.resize(result.sessionId, boot.cols, boot.rows);
return true;
} catch {
// Session expired between list and attach (reaper race) or an older
// gateway: quietly drop the placeholder tab; restore falls back to a
// fresh session when nothing could be reattached.
if (createdTab && !createdTab.gatewaySessionId) {
this.dropFailedTab(createdTab);
}
return false;
}
}
private handleExit(tabId: string, info: { reason?: string; exitCode: number | null }): void {
const tab = this.tabs.find((entry) => entry.id === tabId);
if (!tab) {
return;
}
tab.status = "exited";
tab.statusLabel =
info.reason === "process_exit" && info.exitCode !== null
? t("terminal.exitedCode", { code: String(info.exitCode) })
: t("terminal.exited");
if (info.reason === "detached") {
// Another connection attached this session away; it is alive elsewhere.
tab.statusLabel = t("terminal.detached");
} else {
tab.statusLabel =
info.reason === "process_exit" && info.exitCode !== null
? t("terminal.exitedCode", { code: String(info.exitCode) })
: t("terminal.exited");
}
// The connection drops its own sink on exit delivery, so no release() here —
// the session id may not be recorded yet when an early exit is replayed.
this.tabs = [...this.tabs];
this.persistLiveSessions();
}
private closeTab(tabId: string): void {
@@ -399,6 +518,7 @@ export class OpenClawTerminalPanel extends LitElement {
if (this.activeId === tabId) {
this.activeId = this.tabs.at(-1)?.id ?? null;
}
this.persistLiveSessions();
if (this.tabs.length === 0) {
this.closePanel();
}
@@ -452,6 +572,22 @@ export class OpenClawTerminalPanel extends LitElement {
});
}
/**
* Records which gateway sessions this window's live tabs own so a reload or
* reconnect can reattach them. Intentionally NOT cleared on disconnect
* teardown (disposeAllTabs) — surviving ids are the reattach memory.
*/
private persistLiveSessions(): void {
const ids = this.tabs
.filter((tab) => tab.status === "live" && tab.gatewaySessionId)
.map((tab) => tab.gatewaySessionId);
try {
globalThis.sessionStorage?.setItem(SESSIONS_KEY, JSON.stringify(ids));
} catch {
// Storage may be unavailable (private mode); reattach just won't work.
}
}
private persistLayout(): void {
try {
const layout: PanelLayout = {
+6 -4
View File
@@ -1,11 +1,13 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-04T18:34:36.821Z",
"fallbackKeys": [
"terminal.detached"
],
"generatedAt": "2026-07-04T19:40:56.254Z",
"locale": "ar",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "f2d6998c09b3009d63c98ca8b0187b55c47e60df8b8b33bc1f4dab2ba65130cc",
"totalKeys": 1462,
"sourceHash": "1437172eaecf40feed27c9801bbff868ddc17fbc0769cf1a8d9208f8b3341013",
"totalKeys": 1463,
"translatedKeys": 1462,
"workflow": 1
}
+6 -4
View File
@@ -1,11 +1,13 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-04T18:33:24.509Z",
"fallbackKeys": [
"terminal.detached"
],
"generatedAt": "2026-07-04T19:40:54.271Z",
"locale": "de",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "f2d6998c09b3009d63c98ca8b0187b55c47e60df8b8b33bc1f4dab2ba65130cc",
"totalKeys": 1462,
"sourceHash": "1437172eaecf40feed27c9801bbff868ddc17fbc0769cf1a8d9208f8b3341013",
"totalKeys": 1463,
"translatedKeys": 1462,
"workflow": 1
}
+6 -4
View File
@@ -1,11 +1,13 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-04T18:34:04.861Z",
"fallbackKeys": [
"terminal.detached"
],
"generatedAt": "2026-07-04T19:40:54.790Z",
"locale": "es",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "f2d6998c09b3009d63c98ca8b0187b55c47e60df8b8b33bc1f4dab2ba65130cc",
"totalKeys": 1462,
"sourceHash": "1437172eaecf40feed27c9801bbff868ddc17fbc0769cf1a8d9208f8b3341013",
"totalKeys": 1463,
"translatedKeys": 1462,
"workflow": 1
}
+6 -4
View File
@@ -1,11 +1,13 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-04T18:36:18.937Z",
"fallbackKeys": [
"terminal.detached"
],
"generatedAt": "2026-07-04T19:40:58.431Z",
"locale": "fa",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "f2d6998c09b3009d63c98ca8b0187b55c47e60df8b8b33bc1f4dab2ba65130cc",
"totalKeys": 1462,
"sourceHash": "1437172eaecf40feed27c9801bbff868ddc17fbc0769cf1a8d9208f8b3341013",
"totalKeys": 1463,
"translatedKeys": 1462,
"workflow": 1
}
+6 -4
View File
@@ -1,11 +1,13 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-04T18:34:06.604Z",
"fallbackKeys": [
"terminal.detached"
],
"generatedAt": "2026-07-04T19:40:55.726Z",
"locale": "fr",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "f2d6998c09b3009d63c98ca8b0187b55c47e60df8b8b33bc1f4dab2ba65130cc",
"totalKeys": 1462,
"sourceHash": "1437172eaecf40feed27c9801bbff868ddc17fbc0769cf1a8d9208f8b3341013",
"totalKeys": 1463,
"translatedKeys": 1462,
"workflow": 1
}
+6 -4
View File
@@ -1,11 +1,13 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-04T18:20:57.063Z",
"fallbackKeys": [
"terminal.detached"
],
"generatedAt": "2026-07-04T19:40:55.994Z",
"locale": "hi",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "f2d6998c09b3009d63c98ca8b0187b55c47e60df8b8b33bc1f4dab2ba65130cc",
"totalKeys": 1462,
"sourceHash": "1437172eaecf40feed27c9801bbff868ddc17fbc0769cf1a8d9208f8b3341013",
"totalKeys": 1463,
"translatedKeys": 1462,
"workflow": 1
}
+6 -4
View File
@@ -1,11 +1,13 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-04T18:35:14.841Z",
"fallbackKeys": [
"terminal.detached"
],
"generatedAt": "2026-07-04T19:40:57.137Z",
"locale": "id",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "f2d6998c09b3009d63c98ca8b0187b55c47e60df8b8b33bc1f4dab2ba65130cc",
"totalKeys": 1462,
"sourceHash": "1437172eaecf40feed27c9801bbff868ddc17fbc0769cf1a8d9208f8b3341013",
"totalKeys": 1463,
"translatedKeys": 1462,
"workflow": 1
}
+6 -4
View File
@@ -1,11 +1,13 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-04T18:34:48.573Z",
"fallbackKeys": [
"terminal.detached"
],
"generatedAt": "2026-07-04T19:40:56.509Z",
"locale": "it",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "f2d6998c09b3009d63c98ca8b0187b55c47e60df8b8b33bc1f4dab2ba65130cc",
"totalKeys": 1462,
"sourceHash": "1437172eaecf40feed27c9801bbff868ddc17fbc0769cf1a8d9208f8b3341013",
"totalKeys": 1463,
"translatedKeys": 1462,
"workflow": 1
}
+6 -4
View File
@@ -1,11 +1,13 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-04T18:33:59.819Z",
"fallbackKeys": [
"terminal.detached"
],
"generatedAt": "2026-07-04T19:40:55.171Z",
"locale": "ja-JP",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "f2d6998c09b3009d63c98ca8b0187b55c47e60df8b8b33bc1f4dab2ba65130cc",
"totalKeys": 1462,
"sourceHash": "1437172eaecf40feed27c9801bbff868ddc17fbc0769cf1a8d9208f8b3341013",
"totalKeys": 1463,
"translatedKeys": 1462,
"workflow": 1
}
+6 -4
View File
@@ -1,11 +1,13 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-04T18:34:04.704Z",
"fallbackKeys": [
"terminal.detached"
],
"generatedAt": "2026-07-04T19:40:55.430Z",
"locale": "ko",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "f2d6998c09b3009d63c98ca8b0187b55c47e60df8b8b33bc1f4dab2ba65130cc",
"totalKeys": 1462,
"sourceHash": "1437172eaecf40feed27c9801bbff868ddc17fbc0769cf1a8d9208f8b3341013",
"totalKeys": 1463,
"translatedKeys": 1462,
"workflow": 1
}
+6 -4
View File
@@ -1,11 +1,13 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-04T18:35:55.199Z",
"fallbackKeys": [
"terminal.detached"
],
"generatedAt": "2026-07-04T19:40:58.209Z",
"locale": "nl",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "f2d6998c09b3009d63c98ca8b0187b55c47e60df8b8b33bc1f4dab2ba65130cc",
"totalKeys": 1462,
"sourceHash": "1437172eaecf40feed27c9801bbff868ddc17fbc0769cf1a8d9208f8b3341013",
"totalKeys": 1463,
"translatedKeys": 1462,
"workflow": 1
}
+6 -4
View File
@@ -1,11 +1,13 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-04T18:35:33.847Z",
"fallbackKeys": [
"terminal.detached"
],
"generatedAt": "2026-07-04T19:40:57.414Z",
"locale": "pl",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "f2d6998c09b3009d63c98ca8b0187b55c47e60df8b8b33bc1f4dab2ba65130cc",
"totalKeys": 1462,
"sourceHash": "1437172eaecf40feed27c9801bbff868ddc17fbc0769cf1a8d9208f8b3341013",
"totalKeys": 1463,
"translatedKeys": 1462,
"workflow": 1
}
+6 -4
View File
@@ -1,11 +1,13 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-04T18:33:19.571Z",
"fallbackKeys": [
"terminal.detached"
],
"generatedAt": "2026-07-04T19:40:53.883Z",
"locale": "pt-BR",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "f2d6998c09b3009d63c98ca8b0187b55c47e60df8b8b33bc1f4dab2ba65130cc",
"totalKeys": 1462,
"sourceHash": "1437172eaecf40feed27c9801bbff868ddc17fbc0769cf1a8d9208f8b3341013",
"totalKeys": 1463,
"translatedKeys": 1462,
"workflow": 1
}
+6 -4
View File
@@ -1,11 +1,13 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-04T18:20:59.216Z",
"fallbackKeys": [
"terminal.detached"
],
"generatedAt": "2026-07-04T19:40:58.663Z",
"locale": "ru",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "f2d6998c09b3009d63c98ca8b0187b55c47e60df8b8b33bc1f4dab2ba65130cc",
"totalKeys": 1462,
"sourceHash": "1437172eaecf40feed27c9801bbff868ddc17fbc0769cf1a8d9208f8b3341013",
"totalKeys": 1463,
"translatedKeys": 1462,
"workflow": 1
}
+6 -4
View File
@@ -1,11 +1,13 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-04T18:35:34.946Z",
"fallbackKeys": [
"terminal.detached"
],
"generatedAt": "2026-07-04T19:40:57.670Z",
"locale": "th",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "f2d6998c09b3009d63c98ca8b0187b55c47e60df8b8b33bc1f4dab2ba65130cc",
"totalKeys": 1462,
"sourceHash": "1437172eaecf40feed27c9801bbff868ddc17fbc0769cf1a8d9208f8b3341013",
"totalKeys": 1463,
"translatedKeys": 1462,
"workflow": 1
}
+6 -4
View File
@@ -1,11 +1,13 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-04T18:34:51.187Z",
"fallbackKeys": [
"terminal.detached"
],
"generatedAt": "2026-07-04T19:40:56.713Z",
"locale": "tr",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "f2d6998c09b3009d63c98ca8b0187b55c47e60df8b8b33bc1f4dab2ba65130cc",
"totalKeys": 1462,
"sourceHash": "1437172eaecf40feed27c9801bbff868ddc17fbc0769cf1a8d9208f8b3341013",
"totalKeys": 1463,
"translatedKeys": 1462,
"workflow": 1
}
+6 -4
View File
@@ -1,11 +1,13 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-04T18:34:47.887Z",
"fallbackKeys": [
"terminal.detached"
],
"generatedAt": "2026-07-04T19:40:56.925Z",
"locale": "uk",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "f2d6998c09b3009d63c98ca8b0187b55c47e60df8b8b33bc1f4dab2ba65130cc",
"totalKeys": 1462,
"sourceHash": "1437172eaecf40feed27c9801bbff868ddc17fbc0769cf1a8d9208f8b3341013",
"totalKeys": 1463,
"translatedKeys": 1462,
"workflow": 1
}
+6 -4
View File
@@ -1,11 +1,13 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-04T18:35:30.759Z",
"fallbackKeys": [
"terminal.detached"
],
"generatedAt": "2026-07-04T19:40:57.986Z",
"locale": "vi",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "f2d6998c09b3009d63c98ca8b0187b55c47e60df8b8b33bc1f4dab2ba65130cc",
"totalKeys": 1462,
"sourceHash": "1437172eaecf40feed27c9801bbff868ddc17fbc0769cf1a8d9208f8b3341013",
"totalKeys": 1463,
"translatedKeys": 1462,
"workflow": 1
}
+6 -4
View File
@@ -1,11 +1,13 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-04T18:33:19.432Z",
"fallbackKeys": [
"terminal.detached"
],
"generatedAt": "2026-07-04T19:40:52.333Z",
"locale": "zh-CN",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "f2d6998c09b3009d63c98ca8b0187b55c47e60df8b8b33bc1f4dab2ba65130cc",
"totalKeys": 1462,
"sourceHash": "1437172eaecf40feed27c9801bbff868ddc17fbc0769cf1a8d9208f8b3341013",
"totalKeys": 1463,
"translatedKeys": 1462,
"workflow": 1
}
+6 -4
View File
@@ -1,11 +1,13 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-04T18:33:24.594Z",
"fallbackKeys": [
"terminal.detached"
],
"generatedAt": "2026-07-04T19:40:53.518Z",
"locale": "zh-TW",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "f2d6998c09b3009d63c98ca8b0187b55c47e60df8b8b33bc1f4dab2ba65130cc",
"totalKeys": 1462,
"sourceHash": "1437172eaecf40feed27c9801bbff868ddc17fbc0769cf1a8d9208f8b3341013",
"totalKeys": 1463,
"translatedKeys": 1462,
"workflow": 1
}
+1
View File
@@ -439,6 +439,7 @@ export const ar: TranslationMap = {
tabHint: "{agent} · {cwd}",
exited: "exited",
exitedCode: "exited ({code})",
detached: "detached",
dockBottom: "Dock to bottom",
dockRight: "Dock to right",
},
+1
View File
@@ -443,6 +443,7 @@ export const de: TranslationMap = {
tabHint: "{agent} · {cwd}",
exited: "exited",
exitedCode: "exited ({code})",
detached: "detached",
dockBottom: "Dock to bottom",
dockRight: "Dock to right",
},
+1
View File
@@ -438,6 +438,7 @@ export const en: TranslationMap = {
tabHint: "{agent} · {cwd}",
exited: "exited",
exitedCode: "exited ({code})",
detached: "detached",
dockBottom: "Dock to bottom",
dockRight: "Dock to right",
},
+1
View File
@@ -441,6 +441,7 @@ export const es: TranslationMap = {
tabHint: "{agent} · {cwd}",
exited: "exited",
exitedCode: "exited ({code})",
detached: "detached",
dockBottom: "Dock to bottom",
dockRight: "Dock to right",
},
+1
View File
@@ -441,6 +441,7 @@ export const fa: TranslationMap = {
tabHint: "{agent} · {cwd}",
exited: "exited",
exitedCode: "exited ({code})",
detached: "detached",
dockBottom: "Dock to bottom",
dockRight: "Dock to right",
},
+1
View File
@@ -443,6 +443,7 @@ export const fr: TranslationMap = {
tabHint: "{agent} · {cwd}",
exited: "exited",
exitedCode: "exited ({code})",
detached: "detached",
dockBottom: "Dock to bottom",
dockRight: "Dock to right",
},
+1
View File
@@ -440,6 +440,7 @@ export const hi: TranslationMap = {
tabHint: "{agent} · {cwd}",
exited: "exited",
exitedCode: "exited ({code})",
detached: "detached",
dockBottom: "Dock to bottom",
dockRight: "Dock to right",
},
+1
View File
@@ -440,6 +440,7 @@ export const id: TranslationMap = {
tabHint: "{agent} · {cwd}",
exited: "exited",
exitedCode: "exited ({code})",
detached: "detached",
dockBottom: "Dock to bottom",
dockRight: "Dock to right",
},
+1
View File
@@ -444,6 +444,7 @@ export const it: TranslationMap = {
tabHint: "{agent} · {cwd}",
exited: "exited",
exitedCode: "exited ({code})",
detached: "detached",
dockBottom: "Dock to bottom",
dockRight: "Dock to right",
},
+1
View File
@@ -444,6 +444,7 @@ export const ja_JP: TranslationMap = {
tabHint: "{agent} · {cwd}",
exited: "exited",
exitedCode: "exited ({code})",
detached: "detached",
dockBottom: "Dock to bottom",
dockRight: "Dock to right",
},
+1
View File
@@ -439,6 +439,7 @@ export const ko: TranslationMap = {
tabHint: "{agent} · {cwd}",
exited: "exited",
exitedCode: "exited ({code})",
detached: "detached",
dockBottom: "Dock to bottom",
dockRight: "Dock to right",
},
+1
View File
@@ -442,6 +442,7 @@ export const nl: TranslationMap = {
tabHint: "{agent} · {cwd}",
exited: "exited",
exitedCode: "exited ({code})",
detached: "detached",
dockBottom: "Dock to bottom",
dockRight: "Dock to right",
},
+1
View File
@@ -441,6 +441,7 @@ export const pl: TranslationMap = {
tabHint: "{agent} · {cwd}",
exited: "exited",
exitedCode: "exited ({code})",
detached: "detached",
dockBottom: "Dock to bottom",
dockRight: "Dock to right",
},
+1
View File
@@ -440,6 +440,7 @@ export const pt_BR: TranslationMap = {
tabHint: "{agent} · {cwd}",
exited: "exited",
exitedCode: "exited ({code})",
detached: "detached",
dockBottom: "Dock to bottom",
dockRight: "Dock to right",
},
+1
View File
@@ -443,6 +443,7 @@ export const ru: TranslationMap = {
tabHint: "{agent} · {cwd}",
exited: "exited",
exitedCode: "exited ({code})",
detached: "detached",
dockBottom: "Dock to bottom",
dockRight: "Dock to right",
},
+1
View File
@@ -438,6 +438,7 @@ export const th: TranslationMap = {
tabHint: "{agent} · {cwd}",
exited: "exited",
exitedCode: "exited ({code})",
detached: "detached",
dockBottom: "Dock to bottom",
dockRight: "Dock to right",
},
+1
View File
@@ -442,6 +442,7 @@ export const tr: TranslationMap = {
tabHint: "{agent} · {cwd}",
exited: "exited",
exitedCode: "exited ({code})",
detached: "detached",
dockBottom: "Dock to bottom",
dockRight: "Dock to right",
},
+1
View File
@@ -441,6 +441,7 @@ export const uk: TranslationMap = {
tabHint: "{agent} · {cwd}",
exited: "exited",
exitedCode: "exited ({code})",
detached: "detached",
dockBottom: "Dock to bottom",
dockRight: "Dock to right",
},
+1
View File
@@ -440,6 +440,7 @@ export const vi: TranslationMap = {
tabHint: "{agent} · {cwd}",
exited: "exited",
exitedCode: "exited ({code})",
detached: "detached",
dockBottom: "Dock to bottom",
dockRight: "Dock to right",
},
+1
View File
@@ -437,6 +437,7 @@ export const zh_CN: TranslationMap = {
tabHint: "{agent} · {cwd}",
exited: "exited",
exitedCode: "exited ({code})",
detached: "detached",
dockBottom: "Dock to bottom",
dockRight: "Dock to right",
},
+1
View File
@@ -437,6 +437,7 @@ export const zh_TW: TranslationMap = {
tabHint: "{agent} · {cwd}",
exited: "exited",
exitedCode: "exited ({code})",
detached: "detached",
dockBottom: "Dock to bottom",
dockRight: "Dock to right",
},