<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: s t</title>
    <description>The latest articles on DEV Community by s t (@s_t_ae6f13c69a431fda2ee4b).</description>
    <link>https://dev.to/s_t_ae6f13c69a431fda2ee4b</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4192034%2F3f875d9f-73c5-4140-a474-85279f133d70.png</url>
      <title>DEV Community: s t</title>
      <link>https://dev.to/s_t_ae6f13c69a431fda2ee4b</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/s_t_ae6f13c69a431fda2ee4b"/>
    <language>en</language>
    <item>
      <title>Half-written files and fs.watch on Windows: what I measured, and why rename fixes it</title>
      <dc:creator>s t</dc:creator>
      <pubDate>Wed, 21 Oct 2026 15:57:18 +0000</pubDate>
      <link>https://dev.to/s_t_ae6f13c69a431fda2ee4b/half-written-files-and-fswatch-on-windows-what-i-measured-and-why-rename-fixes-it-2pp</link>
      <guid>https://dev.to/s_t_ae6f13c69a431fda2ee4b/half-written-files-and-fswatch-on-windows-what-i-measured-and-why-rename-fixes-it-2pp</guid>
      <description>&lt;p&gt;A lot of small desktop tools talk to other programs through a drop folder: the writer puts a JSON file in a directory, and the app watches the directory with &lt;code&gt;fs.watch&lt;/code&gt;. It is simple and needs no ports or sockets. It also has a trap: the watcher can see the file before the writer has finished writing it.&lt;/p&gt;

&lt;p&gt;I wanted to know how real that trap is on Windows, so I measured it instead of guessing. Everything below was run on Windows 11 with Node 24. I did not benchmark speed, only which events arrive and what a reader can see.&lt;/p&gt;

&lt;h2&gt;
  
  
  What fs.watch reports for a plain write
&lt;/h2&gt;

&lt;p&gt;In a single process I wrote a 200 KB JSON file with &lt;code&gt;fs.writeFileSync&lt;/code&gt; straight into the watched folder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;rename  a.json
change  a.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So one write gives two events: &lt;code&gt;rename&lt;/code&gt; (the file appeared) and &lt;code&gt;change&lt;/code&gt; (the content was modified). Windows reports a file creation as &lt;code&gt;rename&lt;/code&gt;, which surprises people who expect &lt;code&gt;create&lt;/code&gt;. If the handler assumes one event per file, it runs twice.&lt;/p&gt;

&lt;p&gt;Now the same payload written to &lt;code&gt;b.json.tmp&lt;/code&gt; and then moved with &lt;code&gt;fs.renameSync&lt;/code&gt; to &lt;code&gt;b.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="p"&gt;rename  b.json.tmp
change  b.json.tmp
rename  b.json.tmp
rename  b.json
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The temp name produces its own events (it appears, is modified, then disappears), and the final name gets exactly one &lt;code&gt;rename&lt;/code&gt;. When the consumer later deletes &lt;code&gt;b.json&lt;/code&gt;, that arrives as one more &lt;code&gt;rename&lt;/code&gt;, now with the file gone. Two lessons: filter by the final extension so &lt;code&gt;.tmp&lt;/code&gt; events are ignored, and when you get a &lt;code&gt;rename&lt;/code&gt;, check whether the file still exists. A &lt;code&gt;rename&lt;/code&gt; event does not tell you whether the file was created or removed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The torn read
&lt;/h2&gt;

&lt;p&gt;A same-process test can't show a half-written file, because &lt;code&gt;writeFileSync&lt;/code&gt; has finished before the watcher callback runs. The realistic case is two processes. I made a child process write a 2 MB JSON file in 50 KB chunks with a few milliseconds between them, and read the file from the watcher callback in the parent. Real event files are tiny, so this exaggerates the window. The point is only to show that the failure exists.&lt;/p&gt;

&lt;p&gt;Plain write, straight to &lt;code&gt;e.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;rename e.json  bytes=50021    parses=false
change e.json  bytes=50021    parses=false
change e.json  bytes=2000023  parses=true
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first event fired after the first chunk, and the reader got 50 KB of invalid JSON. Write to &lt;code&gt;e.json.tmp&lt;/code&gt; and rename when finished:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;rename e.json  bytes=2000023  parses=true
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One event, complete file. On the same volume, rename into place is the step that makes the file appear whole, which is why the pattern works. (I am describing what I observed on NTFS here; cross-volume moves are a copy and lose this property.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a delay is not a fix
&lt;/h2&gt;

&lt;p&gt;A common patch is "wait 150 ms, then read". In the plain-write run above, a reader that waits a fixed time is right whenever the writer finishes inside that time and wrong otherwise. The tiny files a hook writes usually finish in well under a millisecond, so it works nearly always, and "nearly always" is the dangerous kind of working. The bigger problem is what happens next. If the reader treats an unparsable file as garbage and deletes it, the event is lost for good: the writer later finishes the file, but it was already removed, and the follow-up &lt;code&gt;change&lt;/code&gt; finds nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern I use now
&lt;/h2&gt;

&lt;p&gt;Writer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tmp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.json.tmp`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tmp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;renameSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tmp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.json`&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reader:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;watch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_e&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;// ignores *.tmp&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;full&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;full&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ENOENT&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c1"&gt;// delete event or already handled&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;                                  &lt;span class="c1"&gt;// leave it; do not delete&lt;/span&gt;
  &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unlinkSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;full&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details matter. First, ignore names that don't end in &lt;code&gt;.json&lt;/code&gt;. Second, treat ENOENT as normal, because the &lt;code&gt;rename&lt;/code&gt; event for your own unlink comes back through the same callback. Third, don't delete a file you could not parse. Quarantine it by renaming it to &lt;code&gt;.bad&lt;/code&gt;, or leave it, so a bug in a writer leaves evidence.&lt;/p&gt;

&lt;p&gt;Also scan the folder once at startup. Events that arrived while the app was closed produce no watcher events at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not cover
&lt;/h2&gt;

&lt;p&gt;Rename makes the file appear whole, but it does not make the event unique. Two writers choosing the same name will overwrite each other, so put a timestamp and a random suffix in the name. It also does nothing about a writer that crashes before the rename: you will be left with stale &lt;code&gt;.tmp&lt;/code&gt; files, which are worth cleaning up on startup.&lt;/p&gt;

&lt;p&gt;My measurements come from one machine, one Node version and one filesystem. A network drive, antivirus filters or a different Windows build may behave differently, so run your own test before relying on any of this.&lt;/p&gt;

&lt;p&gt;If you are building a small Windows tool that listens to hooks from other programs, the one rule that held up for me: write elsewhere, rename into place, and never delete what you cannot read.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Context: I hit this in a tray app I sell, &lt;a href="https://gumroad.com/l/tlwwyz" rel="noopener noreferrer"&gt;CatCode&lt;/a&gt;. I'm the author. The details above apply to any Node watcher, with or without that app.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>electron</category>
      <category>tooling</category>
      <category>node</category>
      <category>windows</category>
    </item>
    <item>
      <title>How a desktop pet finds out your AI agent has finished a task</title>
      <dc:creator>s t</dc:creator>
      <pubDate>Sun, 18 Oct 2026 22:11:31 +0000</pubDate>
      <link>https://dev.to/s_t_ae6f13c69a431fda2ee4b/how-a-desktop-pet-finds-out-your-ai-agent-has-finished-a-task-7ca</link>
      <guid>https://dev.to/s_t_ae6f13c69a431fda2ee4b/how-a-desktop-pet-finds-out-your-ai-agent-has-finished-a-task-7ca</guid>
      <description>&lt;p&gt;CatCode is a pixel pet that lives on the Windows desktop and reacts when a coding agent finishes a task. This is a walkthrough of how that detection works, taken from the app's code (an Electron app). It is simpler than you might expect, and support differs by agent, so I will be specific about what is and is not covered.&lt;/p&gt;

&lt;h2&gt;
  
  
  No log scraping, no process polling
&lt;/h2&gt;

&lt;p&gt;CatCode does not tail agent logs, inspect processes or read any agent's internal files. I checked: there is no such code. The whole mechanism is a &lt;strong&gt;drop folder&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;~/.catcode/events/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anything that can write a small JSON file there can tell the pet a task is done:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Codex"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"task"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"update the UI"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"finished the check"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the entire protocol: &lt;code&gt;source&lt;/code&gt;, &lt;code&gt;task&lt;/code&gt; and &lt;code&gt;message&lt;/code&gt;. Missing values fall back to defaults.&lt;/p&gt;

&lt;h2&gt;
  
  
  The watcher
&lt;/h2&gt;

&lt;p&gt;The main process consumes those files with &lt;code&gt;fs.watch&lt;/code&gt;. Trimmed from &lt;code&gt;main.js&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;consumeEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;full&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;EVENTS_DIR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;loadJSON&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;full&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unlinkSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;full&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// ...append to a per-day log (only the last 3 days are kept)&lt;/span&gt;
  &lt;span class="nf"&gt;celebrate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Task complete\n&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;watchEvents&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readdirSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;EVENTS_DIR&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nf"&gt;consumeEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;watch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;EVENTS_DIR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_e&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;existsSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;EVENTS_DIR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="nf"&gt;consumeEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;150&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Details that come straight from this code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Events that piled up while the app was closed are consumed on startup.&lt;/li&gt;
&lt;li&gt;There is a 150 ms delay before reading, so a half-written file is less likely to be read. It is a mitigation, not a guarantee: the writer uses a plain &lt;code&gt;writeFileSync&lt;/code&gt;, and a file that fails to parse is deleted anyway.&lt;/li&gt;
&lt;li&gt;Each event file is deleted after it is handled. The app keeps a short in-memory list of recent tasks and a three-day per-agent log for a small "agent report" window.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Claude Code: a real hook
&lt;/h2&gt;

&lt;p&gt;For Claude Code the trigger is its &lt;code&gt;Stop&lt;/code&gt; hook. The app has a tray item that writes the hook into &lt;code&gt;~/.claude/settings.json&lt;/code&gt; for you. The resulting entry has this shape (the path to the script is yours to set):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Stop"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"node C:&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;catcode&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;tools&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;emit-event.js &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;Claude Code&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="se"&gt;\"\"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;finished the task&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;emit-event.js&lt;/code&gt; is about 30 lines: it creates the folder if needed and writes &lt;code&gt;{source, task, message}&lt;/code&gt; as a timestamped &lt;code&gt;.json&lt;/code&gt; file. It also accepts JSON on stdin and uses &lt;code&gt;session_id&lt;/code&gt; as the task, if you call it without arguments.&lt;/p&gt;

&lt;p&gt;Two honest caveats. First, &lt;code&gt;Stop&lt;/code&gt; fires when Claude Code finishes responding, so "task finished" really means "the agent stopped and handed control back". Second, the hook command starts with &lt;code&gt;node&lt;/code&gt;, so Node.js needs to be on your PATH.&lt;/p&gt;

&lt;h2&gt;
  
  
  Codex and Cursor: partial, and manual
&lt;/h2&gt;

&lt;p&gt;This is the part to read carefully. The code has &lt;strong&gt;no Codex-specific or Cursor-specific integration&lt;/strong&gt;. No config file for either is written, and nothing watches either tool. The names appear in the README, in the description, and in the tray's "test" button, which just plays a canned message.&lt;/p&gt;

&lt;p&gt;What exists is the generic path: if your tool has a "run a command when finished" mechanism, call the same script with a different source:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node tools/emit-event.js &lt;span class="s2"&gt;"Codex"&lt;/span&gt; &lt;span class="s2"&gt;"update the UI"&lt;/span&gt; &lt;span class="s2"&gt;"finished the check"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If it has no such hook, you can run that line by hand. The pet reacts identically. So: Claude Code has a tray item that writes the hook for you (or you add it by hand, as above), Codex and Cursor work only if you wire a completion command yourself, and I can't tell you from this code whether either tool offers a suitable hook.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the pet does about it
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;celebrate()&lt;/code&gt; sends the &lt;code&gt;happy&lt;/code&gt; state to the renderer, shows a speech bubble for 6 seconds, then restores whatever state the pet was in before.&lt;/p&gt;

&lt;p&gt;There is no sprite sheet. The pet is drawn procedurally: &lt;code&gt;Sprite.makeRenderer(28)&lt;/code&gt; creates a 28x28 offscreen canvas, and primitives such as &lt;code&gt;pdisc&lt;/code&gt;, &lt;code&gt;ptri&lt;/code&gt; and &lt;code&gt;pline&lt;/code&gt; plot pixels, which are then scaled up (110, 140 or 180 px depending on the size setting). A &lt;code&gt;pose()&lt;/code&gt; function maps the state to parameters. For &lt;code&gt;happy&lt;/code&gt;, the code makes the pet bounce, smile, stick out its tongue and wag its tail faster, while the renderer spawns sparks and hearts.&lt;/p&gt;

&lt;p&gt;The full state list is: &lt;code&gt;idle&lt;/code&gt;, &lt;code&gt;working&lt;/code&gt;, &lt;code&gt;waiting&lt;/code&gt;, &lt;code&gt;sleeping&lt;/code&gt;, &lt;code&gt;happy&lt;/code&gt;, &lt;code&gt;play&lt;/code&gt;, &lt;code&gt;run&lt;/code&gt;, &lt;code&gt;pull&lt;/code&gt;. Note that &lt;code&gt;working&lt;/code&gt; (the laptop pose) is set by the Pomodoro focus timer or by hand from the tray, not by agent activity. The pet never knows a task is &lt;em&gt;in progress&lt;/em&gt;, only that one ended.&lt;/p&gt;

&lt;p&gt;Skins are small JSON files rather than images:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ginger-cat"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"species"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cat"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"pattern"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tabby"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"palette"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"body"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"#f2954e"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"shade"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"#c26a2c"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"belly"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"#fbe0c2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
               &lt;/span&gt;&lt;span class="nl"&gt;"stripe"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"#b8531f"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"eye"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"#4fae5a"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"nose"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"#d98c9a"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"inner"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"#f0a9b3"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Species, pattern (&lt;code&gt;solid&lt;/code&gt;, &lt;code&gt;tabby&lt;/code&gt;, &lt;code&gt;patch&lt;/code&gt;), optional accessory and a palette drive the same drawing code, so a new colour scheme needs no art.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rest, briefly
&lt;/h2&gt;

&lt;p&gt;It also does the desktop-pet things: eyes and head follow the cursor, Pomodoro focus and break timers, water and stretch reminders, user reminders, and skin import. Memes and images from the internet are off by default and need to be enabled in the tray. The privacy note lists the services the app contacts directly: ipapi.co and open-meteo.com for weather, plus meme/image services only if you opt in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get it
&lt;/h2&gt;

&lt;p&gt;CatCode is a Windows app, and the source is closed (proprietary EULA). It costs $5: &lt;a href="https://chvdbox.gumroad.com/l/tlwwyz?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=catcode" rel="noopener noreferrer"&gt;CatCode on Gumroad&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The installer is not code-signed, so Windows SmartScreen will show an "unknown publisher" warning on first launch.&lt;/p&gt;

&lt;p&gt;Disclosure: I am the author of CatCode.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>productivity</category>
      <category>windows</category>
      <category>devtools</category>
    </item>
    <item>
      <title>Telegram's WhatsApp import drops your photos: why it happens and how to fix it</title>
      <dc:creator>s t</dc:creator>
      <pubDate>Sun, 18 Oct 2026 12:11:24 +0000</pubDate>
      <link>https://dev.to/s_t_ae6f13c69a431fda2ee4b/telegrams-whatsapp-import-drops-your-photos-why-it-happens-and-how-to-fix-it-3c3o</link>
      <guid>https://dev.to/s_t_ae6f13c69a431fda2ee4b/telegrams-whatsapp-import-drops-your-photos-why-it-happens-and-how-to-fix-it-3c3o</guid>
      <description>&lt;p&gt;You exported a WhatsApp chat, imported it into Telegram, and the text arrived. Every photo, video and voice message turned into a line of text like &lt;code&gt;&amp;lt;attached: IMG-20231104-WA0007.jpg&amp;gt;&lt;/code&gt;. The memories are gone and only placeholders remain.&lt;/p&gt;

&lt;p&gt;You did nothing wrong, and your export is fine. This is a known Telegram bug that has been open for over a year on Telegram's own tracker (&lt;a href="https://bugs.telegram.org/c/49513" rel="noopener noreferrer"&gt;bugs.telegram.org/c/49513&lt;/a&gt;), with the same complaint in &lt;a href="https://bugs.telegram.org/c/39114" rel="noopener noreferrer"&gt;other bug threads&lt;/a&gt;, r/Telegram and Superuser. The text transfers and the media is silently skipped.&lt;/p&gt;

&lt;p&gt;This post explains why it happens, and how to import a WhatsApp chat into Telegram with the original dates and the media actually attached: photos, videos, voice messages, round video notes and stickers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the import loses your media
&lt;/h2&gt;

&lt;p&gt;A WhatsApp export is a &lt;code&gt;.zip&lt;/code&gt; (or folder) containing a &lt;code&gt;_chat.txt&lt;/code&gt; transcript plus the media files. Inside the transcript each media item is only a reference, such as &lt;code&gt;IMG-20231104-WA0007.jpg (file attached)&lt;/code&gt;. The bytes live in separate image, video and audio files next to the transcript.&lt;/p&gt;

&lt;p&gt;Telegram's history import is a multi-step flow. The client is supposed to initialize the import, upload each referenced media file, then finalize so the server stitches the media back onto the right messages. From what I can tell, the problem is in the middle step: the text arrives, but the media never gets uploaded, so the server never receives the files and every attachment stays a text stub. Telegram has not confirmed the exact cause. This is my reading of the API flow, not an official statement.&lt;/p&gt;

&lt;p&gt;In one sentence: the media is in your export, but the app's import never gets it to Telegram's servers. Nothing you change on the WhatsApp side fixes that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: do the media upload step yourself
&lt;/h2&gt;

&lt;p&gt;Telegram exposes an official history-import API with the three calls the app is meant to use:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;messages.initHistoryImport&lt;/code&gt; starts the import for a chat.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;messages.uploadImportedMedia&lt;/code&gt; uploads each photo, video, voice note, round video and sticker. This is the step that appears to be missing from the app's import.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;messages.startHistoryImport&lt;/code&gt; finalizes, so the server attaches every file to the correct message and keeps the original send dates.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;tg-import&lt;/strong&gt; is a small Windows tool I wrote that drives all three calls. It reads a WhatsApp export (&lt;code&gt;.zip&lt;/code&gt; or folder) or a Telegram Desktop export (&lt;code&gt;result.json&lt;/code&gt;), matches each &lt;code&gt;&amp;lt;attached: ...&amp;gt;&lt;/code&gt; reference to its real file, and performs the upload step properly.&lt;/p&gt;

&lt;p&gt;What it does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Transfers photos, videos, voice messages, round video notes and stickers instead of placeholders.&lt;/li&gt;
&lt;li&gt;Preserves original dates (see the 1:1 limit below).&lt;/li&gt;
&lt;li&gt;Has a dry-run preview (&lt;code&gt;--dry&lt;/code&gt;) that shows what will be imported with no login and no upload.&lt;/li&gt;
&lt;li&gt;Resumes after a crash instead of re-uploading everything.&lt;/li&gt;
&lt;li&gt;Uploads in batches and handles Telegram's flood limits.&lt;/li&gt;
&lt;li&gt;Ships as a single Windows &lt;code&gt;.exe&lt;/code&gt;, so there is no Python to install.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How to use it
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Export your chat. In WhatsApp open the chat, choose Export chat, then &lt;strong&gt;Include media&lt;/strong&gt;. (Or export from Telegram Desktop as &lt;code&gt;result.json&lt;/code&gt;.)&lt;/li&gt;
&lt;li&gt;Create a free &lt;code&gt;api_id&lt;/code&gt; / &lt;code&gt;api_hash&lt;/code&gt; at &lt;a href="https://my.telegram.org" rel="noopener noreferrer"&gt;my.telegram.org&lt;/a&gt;. These are your own credentials and nothing is sent to a third party.&lt;/li&gt;
&lt;li&gt;Preview first. Run tg-import with &lt;code&gt;--dry&lt;/code&gt; to see every message and media file it will import.&lt;/li&gt;
&lt;li&gt;Run the import against a private 1:1 chat. It does the init, upload and start sequence, batches uploads, and resumes if interrupted.&lt;/li&gt;
&lt;li&gt;Check the chat. Your media is there on the original dates, with Telegram's small "Imported" label.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Limits you should know before you try it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Original dates only land correctly in a private 1:1 chat.&lt;/strong&gt; This is a Telegram server limitation, not a tool bug. Importing into a group or channel will not place messages on their original dates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bulk import through the user API is a gray area under Telegram's Terms of Service.&lt;/strong&gt; tg-import uses your own account and credentials, so it is your account and your own risk. The tool paces requests for flood limits, but it cannot remove the ToS question. If that risk is not acceptable to you, this is not the right tool.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You supply your own &lt;code&gt;api_id&lt;/code&gt; and &lt;code&gt;api_hash&lt;/code&gt;.&lt;/strong&gt; They are free from my.telegram.org.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Get it
&lt;/h2&gt;

&lt;p&gt;tg-import costs $9 on &lt;a href="https://chvdbox.gumroad.com/l/amycht?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=tg-import" rel="noopener noreferrer"&gt;Gumroad&lt;/a&gt;. Run the &lt;code&gt;--dry&lt;/code&gt; preview first: no login, no upload.&lt;/p&gt;

&lt;p&gt;Disclosure: I am the author of tg-import.&lt;/p&gt;

</description>
      <category>telegram</category>
      <category>whatsapp</category>
      <category>python</category>
      <category>tooling</category>
    </item>
  </channel>
</rss>
