Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
de48830
implement the remote side of the termlisten protocol
sawka Apr 27, 2026
1e14a37
updates for wshcmdreader to support multiple osc sequences
sawka Apr 27, 2026
868470f
md formatting
sawka Apr 27, 2026
3d442dc
termlistensrv + working http integration test
sawka Apr 27, 2026
507a988
implement termproxy support in tsunami command
sawka Apr 27, 2026
bb8ba32
default raw tsunami binaries to termproxy, all wave paths disable it …
sawka Apr 27, 2026
50ee4e3
commit generated file update
sawka Apr 27, 2026
6fb3ffc
integrate server side via ptybuffer
sawka Apr 27, 2026
003d073
checkpoint, got tsunami sub-block and client<->server signaling working
sawka Apr 27, 2026
6d7790c
fix some bugs (more remaining)
sawka Apr 27, 2026
b335043
checkpoint, fixing bugs
sawka Apr 28, 2026
4db2737
fix bugs, term mode switcher, preload changes, etc.
sawka Apr 28, 2026
7945bd1
fix manifest for pre-build binaries
sawka Apr 28, 2026
802f928
meta sync + header
sawka May 1, 2026
f61eb14
better global keybindngs for builder window
sawka May 1, 2026
c7480fb
small change to publish dialog
sawka May 1, 2026
e96a78b
update to gpt-5.5 for builder
sawka May 1, 2026
a947688
fix header icons for tsunami sub-blocks
sawka May 1, 2026
5aea889
fix tsunami sub-block menu items
sawka May 1, 2026
17e636d
show app name in header in tsunami blocks
sawka May 1, 2026
db626d0
simplify, use tsunamidirect
sawka May 2, 2026
5925b66
update copyright years
sawka May 2, 2026
cd36896
more simplifications to tsunami now that we have tsunamidirect
sawka May 2, 2026
14bd2d0
move allowtermlisten to a global config setting
sawka May 2, 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
Prev Previous commit
Next Next commit
simplify, use tsunamidirect
  • Loading branch information
sawka committed May 2, 2026
commit db626d00bc9b5f304cda53c85d57fe2424452155
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,4 @@ docsite/
.superpowers
docs/superpowers
.claude
CLAUDE.local.md
2 changes: 2 additions & 0 deletions frontend/app/block/blockregistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { PreviewModel } from "@/app/view/preview/preview-model";
import { ProcessViewerViewModel } from "@/app/view/processviewer/processviewer";
import { SysinfoViewModel } from "@/app/view/sysinfo/sysinfo";
import { TsunamiViewModel } from "@/app/view/tsunami/tsunami";
import { TsunamiDirectViewModel } from "@/app/view/tsunamidirect/tsunamidirect";
import { VDomModel } from "@/app/view/vdom/vdom-model";
import { WaveEnv } from "@/app/waveenv/waveenv";
import { atom } from "jotai";
Expand All @@ -32,6 +33,7 @@ BlockRegistry.set("tips", QuickTipsViewModel);
BlockRegistry.set("help", HelpViewModel);
BlockRegistry.set("launcher", LauncherViewModel);
BlockRegistry.set("tsunami", TsunamiViewModel);
BlockRegistry.set("tsunamidirect", TsunamiDirectViewModel);
BlockRegistry.set("aifilediff", AiFileDiffViewModel);
BlockRegistry.set("waveconfig", WaveConfigViewModel);
BlockRegistry.set("processviewer", ProcessViewerViewModel);
Expand Down
4 changes: 2 additions & 2 deletions frontend/app/view/term/osc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -411,14 +411,14 @@ export async function handleOsc9009Command(data: string, blockId: string, loaded

const oref = await RpcApi.CreateSubBlockCommand(TabRpcClient, {
parentblockid: blockId,
blockdef: { meta: { view: "tsunami", "tsunami:url": tsunamiUrl, "tsunami:termlisten": true, "tsunami:port": payload.port, "tsunami:parentblockid": blockId } },
blockdef: { meta: { view: "tsunamidirect", "tsunami:url": tsunamiUrl, "tsunami:parentblockid": blockId } },
});
Comment on lines +395 to +415
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Verify the advertised port before opening a localhost block.

Any process that can write to this terminal can emit OSC 9009 with { "termlisten": true, "port": N }, and this handler will immediately create a tsunamidirect block to http://localhost:N. That crosses a trust boundary and lets terminal output target arbitrary local services. Validate that port is an integer in range and belongs to an active termlisten registration before creating the sub-block.

Proposed fix
     try {
         payload = JSON.parse(jsonStr);
     } catch {
         return true;
     }
     if (!payload.port || !payload.termlisten) return true;
+    if (!Number.isInteger(payload.port) || payload.port < 1 || payload.port > 65535) return true;
+    const active = await RpcApi.TermListenCheckPortCommand(TabRpcClient, { port: payload.port });
+    if (!active) return true;
 
     const tsunamiUrl = `http://localhost:${payload.port}`;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/view/term/osc-handlers.ts` around lines 395 - 415, The handler
currently accepts any JSON with payload.port and creates a tsunamidirect block
to http://localhost:port, which is a trust boundary violation; change the logic
in the JSON-parsing block that reads payload.port / payload.termlisten to (1)
validate payload.port is a number and an integer in the TCP port range
(1–65535), and (2) verify the port has an active termlisten registration before
calling RpcApi.CreateSubBlockCommand(TabRpcClient, ...). Use the existing
state/registry (e.g. check globalStore for a termlisten registration entry or
call the project’s termlisten lookup helper) rather than blindly trusting the
incoming payload, and return true (no-op) if the port is out of range or not
registered; keep getBlockMetaKeyAtom, globalStore, and
RpcApi.CreateSubBlockCommand usage unchanged aside from gating that call.

const [, newBlockId] = splitORef(oref);

setTimeout(() => {
RpcApi.SetMetaCommand(TabRpcClient, {
oref: WOS.makeORef("block", blockId),
meta: { "term:tsunamiblockid": newBlockId, "term:mode": "tsunami" },
meta: { "term:tsunamiblockid": newBlockId, "term:mode": "tsunami", "term:tsunamilocalport": payload.port },
});
}, 50);
Comment on lines +405 to +423
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Only delete the current tsunami block after the replacement is committed.

oldBlockId is scheduled for deletion before CreateSubBlockCommand and the parent SetMetaCommand succeed. If either RPC fails, the working block is still torn down 500ms later.

Proposed fix
     const oldBlockId = globalStore.get(getBlockMetaKeyAtom(blockId, "term:tsunamiblockid"));
-    if (oldBlockId) {
-        setTimeout(() => {
-            RpcApi.DeleteSubBlockCommand(TabRpcClient, { blockid: oldBlockId });
-        }, 500);
-    }
-
     const oref = await RpcApi.CreateSubBlockCommand(TabRpcClient, {
         parentblockid: blockId,
         blockdef: { meta: { view: "tsunamidirect", "tsunami:url": tsunamiUrl, "tsunami:parentblockid": blockId } },
     });
     const [, newBlockId] = splitORef(oref);
-
-    setTimeout(() => {
-        RpcApi.SetMetaCommand(TabRpcClient, {
-            oref: WOS.makeORef("block", blockId),
-            meta: { "term:tsunamiblockid": newBlockId, "term:mode": "tsunami", "term:tsunamilocalport": payload.port },
-        });
-    }, 50);
+    await RpcApi.SetMetaCommand(TabRpcClient, {
+        oref: WOS.makeORef("block", blockId),
+        meta: { "term:tsunamiblockid": newBlockId, "term:mode": "tsunami", "term:tsunamilocalport": payload.port },
+    });
+    if (oldBlockId) {
+        setTimeout(() => {
+            RpcApi.DeleteSubBlockCommand(TabRpcClient, { blockid: oldBlockId });
+        }, 500);
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const oldBlockId = globalStore.get(getBlockMetaKeyAtom(blockId, "term:tsunamiblockid"));
if (oldBlockId) {
setTimeout(() => {
RpcApi.DeleteSubBlockCommand(TabRpcClient, { blockid: oldBlockId });
}, 500);
}
const oref = await RpcApi.CreateSubBlockCommand(TabRpcClient, {
parentblockid: blockId,
blockdef: { meta: { view: "tsunamidirect", "tsunami:url": tsunamiUrl, "tsunami:parentblockid": blockId } },
});
const [, newBlockId] = splitORef(oref);
setTimeout(() => {
RpcApi.SetMetaCommand(TabRpcClient, {
oref: WOS.makeORef("block", blockId),
meta: { "term:tsunamiblockid": newBlockId, "term:mode": "tsunami", "term:tsunamilocalport": payload.port },
});
}, 50);
const oldBlockId = globalStore.get(getBlockMetaKeyAtom(blockId, "term:tsunamiblockid"));
const oref = await RpcApi.CreateSubBlockCommand(TabRpcClient, {
parentblockid: blockId,
blockdef: { meta: { view: "tsunamidirect", "tsunami:url": tsunamiUrl, "tsunami:parentblockid": blockId } },
});
const [, newBlockId] = splitORef(oref);
await RpcApi.SetMetaCommand(TabRpcClient, {
oref: WOS.makeORef("block", blockId),
meta: { "term:tsunamiblockid": newBlockId, "term:mode": "tsunami", "term:tsunamilocalport": payload.port },
});
if (oldBlockId) {
setTimeout(() => {
RpcApi.DeleteSubBlockCommand(TabRpcClient, { blockid: oldBlockId });
}, 500);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/view/term/osc-handlers.ts` around lines 405 - 423, The code
schedules RpcApi.DeleteSubBlockCommand for oldBlockId before the new block
creation and meta update complete, risking premature teardown; instead, await
RpcApi.CreateSubBlockCommand (oref from CreateSubBlockCommand and splitORef) and
ensure RpcApi.SetMetaCommand (using WOS.makeORef for parent blockId) completes
successfully, then call RpcApi.DeleteSubBlockCommand for oldBlockId (from
globalStore.get(getBlockMetaKeyAtom(...)))—remove the preemptive setTimeout
delete and perform the delete in a try/catch after CreateSubBlockCommand and
SetMetaCommand succeed so failures don’t remove the active block.


Expand Down
24 changes: 20 additions & 4 deletions frontend/app/view/term/term-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { makeFeBlockRouteId } from "@/app/store/wshrouter";
import { DefaultRouter, TabRpcClient } from "@/app/store/wshrpcutil";
import { TermClaudeIcon, TerminalView } from "@/app/view/term/term";
import { TermWshClient } from "@/app/view/term/term-wsh";
import type { TsunamiViewModel } from "@/app/view/tsunami/tsunami";
import type { TsunamiDirectViewModel } from "@/app/view/tsunamidirect/tsunamidirect";
import { VDomModel } from "@/app/view/vdom/vdom-model";
import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model";
import {
Expand Down Expand Up @@ -65,6 +65,7 @@ export class TermViewModel implements ViewModel {
vdomBlockId: jotai.Atom<string>;
vdomToolbarBlockId: jotai.Atom<string>;
tsunamiBlockId: jotai.Atom<string>;
tsunamiLocalPort: jotai.Atom<number>;
vdomToolbarTarget: jotai.PrimitiveAtom<VDomTargetToolbar>;
fontSizeAtom: jotai.Atom<number>;
termThemeNameAtom: jotai.Atom<string>;
Expand Down Expand Up @@ -108,6 +109,10 @@ export class TermViewModel implements ViewModel {
const blockData = get(this.blockAtom);
return blockData?.meta?.["term:tsunamiblockid"];
});
this.tsunamiLocalPort = jotai.atom((get) => {
const blockData = get(this.blockAtom);
return blockData?.meta?.["term:tsunamilocalport"] ?? 0;
});
this.vdomToolbarTarget = jotai.atom<VDomTargetToolbar>(null) as jotai.PrimitiveAtom<VDomTargetToolbar>;
this.termMode = jotai.atom((get) => {
const blockData = get(this.blockAtom);
Expand All @@ -122,7 +127,7 @@ export class TermViewModel implements ViewModel {
if (termMode == "tsunami") {
const tsunamiBlockId = get(this.tsunamiBlockId);
const tsunamiBcm = tsunamiBlockId ? get(getBlockComponentModelAtom(tsunamiBlockId)) : null;
const tsunamiVM = tsunamiBcm?.viewModel as TsunamiViewModel;
const tsunamiVM = tsunamiBcm?.viewModel as TsunamiDirectViewModel;
if (tsunamiVM?.viewIcon) {
return get(tsunamiVM.viewIcon);
}
Expand All @@ -139,7 +144,7 @@ export class TermViewModel implements ViewModel {
if (termMode == "tsunami") {
const tsunamiBlockId = get(this.tsunamiBlockId);
const tsunamiBcm = tsunamiBlockId ? get(getBlockComponentModelAtom(tsunamiBlockId)) : null;
const tsunamiVM = tsunamiBcm?.viewModel as TsunamiViewModel;
const tsunamiVM = tsunamiBcm?.viewModel as TsunamiDirectViewModel;
if (tsunamiVM?.viewName) {
return get(tsunamiVM.viewName);
}
Expand Down Expand Up @@ -635,6 +640,17 @@ export class TermViewModel implements ViewModel {
return bcm.viewModel as VDomModel;
}

teardownTsunamiSubBlock() {
const tsunamiBlockId = globalStore.get(this.tsunamiBlockId);
if (tsunamiBlockId) {
RpcApi.DeleteSubBlockCommand(TabRpcClient, { blockid: tsunamiBlockId });
}
RpcApi.SetMetaCommand(TabRpcClient, {
oref: WOS.makeORef("block", this.blockId),
meta: { "term:mode": null, "term:tsunamiblockid": null, "term:tsunamilocalport": null },
});
}

focusTsunamiSubBlock() {
const tsunamiBlockId = globalStore.get(this.tsunamiBlockId);
if (!tsunamiBlockId) return;
Expand Down Expand Up @@ -1008,7 +1024,7 @@ export class TermViewModel implements ViewModel {
if (termMode === "tsunami") {
const tsunamiBlockId = globalStore.get(this.tsunamiBlockId);
const bcm = tsunamiBlockId ? globalStore.get(getBlockComponentModelAtom(tsunamiBlockId)) : null;
const tsunamiVM = bcm?.viewModel as TsunamiViewModel;
const tsunamiVM = bcm?.viewModel as TsunamiDirectViewModel;
const promotedItems = tsunamiVM?.getPromotedContextMenuItems?.() ?? [];
fullMenu.push({
label: "Send ^C to Tsunami App",
Expand Down
61 changes: 23 additions & 38 deletions frontend/app/view/term/term.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -183,61 +183,46 @@ const TermTsunamiNodeSingleId = ({
blockId,
model,
}: TerminalViewProps & { tsunamiBlockId: string }) => {
const [tsunamiBlock] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", tsunamiBlockId));
const tsunamiPort = tsunamiBlock?.meta?.["tsunami:port"] ?? 0;
const tsunamiPort = jotai.useAtomValue(model.tsunamiLocalPort);

React.useEffect(() => {
console.log("[tsunami] TermTsunamiNodeSingleId effect", { tsunamiBlockId, blockId, tsunamiPort });
const hardTeardown = (reason: string) => {
console.log("[tsunami] hardTeardown", reason, { tsunamiBlockId, blockId });
RpcApi.DeleteSubBlockCommand(TabRpcClient, { blockid: tsunamiBlockId });
RpcApi.SetMetaCommand(TabRpcClient, {
oref: WOS.makeORef("block", blockId),
meta: { "term:mode": null, "term:tsunamiblockid": null },
if (!(tsunamiPort > 0)) {
model.teardownTsunamiSubBlock();
return;
}
let cancelled = false;
RpcApi.TermListenCheckPortCommand(TabRpcClient, { port: tsunamiPort })
.then((active) => {
if (!cancelled && !active) {
model.teardownTsunamiSubBlock();
}
})
.catch(() => {
if (!cancelled) {
model.teardownTsunamiSubBlock();
}
});
return () => {
cancelled = true;
};
let cancelled = false;
if (tsunamiPort > 0) {
console.log("[tsunami] checking port", tsunamiPort);
RpcApi.TermListenCheckPortCommand(TabRpcClient, { port: tsunamiPort })
.then((active) => {
console.log("[tsunami] port check result", { port: tsunamiPort, active, cancelled });
if (!cancelled && !active) {
hardTeardown("port-check-inactive");
}
})
.catch((err) => {
console.log("[tsunami] port check error", err);
if (!cancelled) {
hardTeardown("port-check-error");
}
});
} else {
console.log("[tsunami] tsunamiPort=0, skipping port check");
}
}, [tsunamiPort]);

React.useEffect(() => {
const unsubBlockClose = waveEventSubscribeSingle({
eventType: "blockclose",
scope: WOS.makeORef("block", tsunamiBlockId),
handler: (_event) => {
console.log("[tsunami] blockclose event fired", { tsunamiBlockId });
RpcApi.SetMetaCommand(TabRpcClient, {
oref: WOS.makeORef("block", blockId),
meta: { "term:mode": null, "term:tsunamiblockid": null },
});
},
handler: () => model.teardownTsunamiSubBlock(),
});
const unsubTermListenDown = waveEventSubscribeSingle({
eventType: "termlisten:down",
scope: WOS.makeORef("block", blockId),
handler: (event) => {
console.log("[tsunami] termlisten:down event", { port: event.data?.port, tsunamiPort });
if (tsunamiPort > 0 && event.data?.port === tsunamiPort) {
hardTeardown("termlisten-down");
model.teardownTsunamiSubBlock();
}
},
});
return () => {
cancelled = true;
unsubBlockClose();
unsubTermListenDown();
};
Expand Down
54 changes: 0 additions & 54 deletions frontend/app/view/tsunami/tsunami.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { RpcApi } from "@/app/store/wshclientapi";
import { TabRpcClient } from "@/app/store/wshrpcutil";
import { WebView, WebViewModel } from "@/app/view/webview/webview";
import * as services from "@/store/services";
import { stringToBase64 } from "@/util/util";
import * as jotai from "jotai";
import { memo, useEffect } from "react";

Expand Down Expand Up @@ -253,64 +252,11 @@ const TsunamiView = memo((props: ViewComponentProps<TsunamiViewModel>) => {
const shellProcFullStatus = jotai.useAtomValue(model.shellProcFullStatus);
const blockData = jotai.useAtomValue(model.blockAtom);
const isRestarting = jotai.useAtomValue(model.isRestarting);
const domReady = jotai.useAtomValue(model.domReady);

useEffect(() => {
model.resyncController();
}, [model]);

const tsunamiDirectUrl = blockData?.meta?.["tsunami:url"];
const tsunamiParentBlockId = blockData?.meta?.["tsunami:parentblockid"];

useEffect(() => {
if (!domReady || !tsunamiParentBlockId || !model.webviewRef.current) return;
const webview = model.webviewRef.current;
webview.send("enable-tsunami-termlisten", tsunamiParentBlockId);
const handler = (event: any) => {
if (event.channel !== "tsunami-key") return;
const { key } = event.args[0];
if (key === "cmd-escape") {
RpcApi.SetMetaCommand(TabRpcClient, {
oref: WOS.makeORef("block", tsunamiParentBlockId),
meta: { "term:mode": null },
});
return;
}
let inputData: string | null = null;
if (key === "ctrl-c") inputData = "\x03";
else if (key === "ctrl-z") inputData = "\x1a";
if (!inputData) return;
RpcApi.ControllerInputCommand(TabRpcClient, {
blockid: tsunamiParentBlockId,
inputdata64: stringToBase64(inputData),
});
};
webview.addEventListener("ipc-message", handler);
return () => {
webview.removeEventListener("ipc-message", handler);
};
}, [domReady, tsunamiParentBlockId]);

useEffect(() => {
if (!tsunamiDirectUrl) return;
fetch(`${tsunamiDirectUrl}/api/manifest`)
.then((r) => r.json())
.then((manifest: AppManifest) => {
if (manifest?.appmeta) {
globalStore.set(model.appMeta, manifest.appmeta);
}
})
.catch(() => {});
}, [tsunamiDirectUrl]);

if (tsunamiDirectUrl) {
return (
<div className="w-full h-full">
<WebView {...props} initialSrc={`${tsunamiDirectUrl}/?clientid=wave:${model.blockId}`} />
</div>
);
}

const appPath = blockData?.meta?.["tsunami:apppath"];
const appId = blockData?.meta?.["tsunami:appid"];
const controller = blockData?.meta?.controller;
Expand Down
Loading