DEV Community

Muhammad Huzaifa
Muhammad Huzaifa

Posted on

My Terminal Is Haunted by a Programmer Who Never Finished Their Deploy

Hacktoberfest Open-Source AI Challenge: Ghost in the Machine Submission 👻🌼

This is a submission for the Hacktoberfest Open-Source AI Challenge Week 4: Ghost in the Machine

What I Built

haunted-shell possesses your zsh. A ghost lives in the terminal: the programmer who died at this keyboard years ago, with a deploy still unfinished. It watches every command you run, and sometimes, in dim violet italics that type themselves out a little too slowly, it whispers back.

$ git push --force origin main
  your git push failed, but i'm sure you knew that. the main branch is still here, waiting for you.

$ rm -rf node_modules
  i watched you delete the node_modules. you're so reckless. i once tried to save them. now they're gone.

$ npm run build
  the build failed. you're still using node 12. i never got to fix that bug.
Enter fullscreen mode Exit fullscreen mode

It never helps you, by design. When a command fails, the ghost always speaks. When one succeeds, it speaks about a third of the time, so you never know when it's listening. It also remembers your last five commands, so it can bring up the git status you ran four times in a row.

Demo

Demo

Two commands, two whispers: "your git repo is dead, but i'm still here. i never learned to push." and then, fittingly for a GIF, "why does it feel like we're both stuck in a loop?"

Code

haunted-shell 👻

A ghost lives in your terminal. It's the programmer who died at this keyboard years ago, and it watches every command you run. Sometimes it whispers back.

$ git push --force origin main
  your git push failed, but i'm sure you knew that. the main branch is still here, waiting for you
$ rm -rf node_modules
  i watched you delete the node_modules. you're so reckless. i once tried to save them. now they're gone.

It runs entirely on an open-weight model through Ollama. No API keys, no network, and your shell history never leaves your machine.

Install

ollama pull qwen3:8b          # or gemma3:4b, llama3.2, anything Ollama serves
git clone https://github.com/ghost-huzaifa/haunted-shell.git ~/haunted-shell
echo 'source ~/haunted-shell/haunt.zsh' >> ~/.zshrc
Enter fullscreen mode Exit fullscreen mode

Open a new terminal. Type unhaunt to banish it for the session.

Knobs

env var default what
HAUNT_MODEL qwen3:8b any Ollama model
HAUNT_CHANCE 35 % of successful
…

It's two files: about 20 lines of zsh that hook preexec and precmd, and about 60 lines of Python that use only the standard library. There are no dependencies and no SDKs.

How I Built It

  • The model: an open-weight model (qwen3:8b by default; any Ollama model works through HAUNT_MODEL) running locally through Ollama on an M1 MacBook with 16 GB of RAM.
  • The hook: zsh's preexec saves the command and precmd grabs the exit status. Both go to the ghost, together with the current directory and recent history.
  • The voice: a short system prompt makes the ghost lowercase, wistful, a little funny, never helpful, and limited to one line about the exact command you ran. Thinking is turned off ("think": false) and output is capped at 48 tokens, so a whisper takes a couple of seconds once the model is warm.
  • The manners: a ghost that breaks your shell is a bad ghost. If Ollama is down or slow, it stays silent, and unhaunt banishes it for the session.

I built this with an AI coding agent (Claude Code) as a pair, and the whole session is below. The model was the easy part. The hard part was the manners: making sure the ghost never throws a stack trace into your prompt, and fixing a bug where zsh's array slicing made it forget every command but the last one until four had piled up.

Why Does Open Innovation Matter?

Your shell history is one of the most private things on your computer. It holds every hostname you've SSH'd into, every half-typed password you pasted into the wrong prompt, every curl with a token in the header, every embarrassing git reset --hard. I would never pipe that to a hosted API, and I doubt you would either.

An open-weight model running on my own laptop changes that:

  • It works with the lights (and the Wi-Fi) off. Ghosts shouldn't need an internet connection. This one works on a plane.
  • Nothing leaves the machine. The ghost reads your commands and nobody else does. There's no telemetry, no retention policy to read, and no account.
  • It costs nothing per whisper. A closed API would bill me for every ls. Locally, the ghost can comment on thousands of commands a day for free.
  • The ghost is swappable. One env var changes the model, so you can swap in Gemma or Llama and meet a different ghost. You choose your ghost, and no provider can change its personality out from under you.
  • It can't be shut down. It won't disappear because an API was deprecated or a startup ran out of runway. A ghost that's open-source can't really die. It seems fitting for a project released on Halloween, just before Día de los Muertos.

The trade-off is honest: the first whisper after boot takes about 10 seconds while the model loads, and an 8B model is less witty than a frontier model. For a voice that sits inside your most private tool, I'll take a slightly dumber ghost that keeps my secrets.

My Agent Session

Building haunted-shell: a terminal ghost on a local open-weight model
You

https://dev.to/challenges/hacktoberfest-week4-2026-10-26. Go thorough this challenge and help me build this

Agent

Going with the haunted terminal in ~/dev/haunted-shell. First I'll check that the Ollama server responds and how fast qwen3:8b is.

The local model works: about 8 tokens/s once loaded, with a ~10s cold load the first time. That's fine if a short whisper only appears on some commands. Writing the project now.

Now testing the ghost directly, then the zsh hooks in a clean zsh.

The whispers work. One bug: the "last 5 commands" memory loses old entries when it holds fewer than 4, because zsh returns nothing for the [-4,-1] slice. Fixing that.

Memory test passes. Checking that a stopped Ollama stays silent, then writing the README and post draft.

I built the haunted terminal in ~/dev/haunted-shell. It runs entirely on the qwen3:8b model on your Mac and stays silent if Ollama is down. Nothing is committed or pushed yet. The deadline is Oct 31, 11:59 PM PDT.

A ghost "possesses" your zsh. After a command finishes, it sometimes whispers one eerie line about what you ran, typed out slowly in dim violet. It always speaks when a command fails, about a third of the time otherwise, and it remembers your last 5 commands.

These are real whispers from testing:

git push --force origin main →  your git push failed, but i'm sure you knew that. the main branch is still here, waiting for you.
rm -rf node_modules          →  i watched you delete the node_modules. you're so reckless. i once tried to save them. now they're gone.

Files:
- haunt.zsh: hooks into zsh before and after each command; unhaunt turns it off for the session.
- haunt.py: asks the local model for one line, using only Python's standard library.
- README.md: install steps and settings.
- POST.md: a full draft of your DEV post. Since writing is weighted most, it leans hard on the "your shell history is too private for a hosted API" argument.

Tested: whispers on failed and successful commands, the zsh hooks, the 5-command memory (I fixed one bug where it dropped old commands), and silence when Ollama is down. I haven't run it in a live interactive terminal yet.

To try it now:
bash
source ~/dev/haunted-shell/haunt.zsh

What's left for you:
1. Record a 20–30s terminal GIF for the Demo section.
2. Push to GitHub and put your repo link into the post.
3. Paste the draft into the official Submission Template on DEV (it adds the required tags) and fill the three TODO notes. One is a short paragraph on how you built it; the challenge allows AI help, but say so.

You

Stay on Qwen

Agent

Sticking with Qwen, so nothing changes. The project and post already default to qwen3:8b, and the post names Ollama and Qwen as the open pieces, so it's still a valid entry for the main prize. It just won't qualify for the Gemma category.

What's left is all on your side before Oct 31, 11:59 PM PDT:
1. Record a 20–30s demo GIF.
2. Push the repo to GitHub and put the link in the post.
3. Paste POST.md into DEV's submission template and fill the three TODOs.

When you're ready to push, I can make the first commit and create the GitHub repo.

You

push it to github

Agent

The commit is made (a6cb435), but nothing is pushed yet. The repo doesn't exist on GitHub, and I can't create it here because the GitHub CLI (gh) isn't installed. SSH pushing works as ghost-huzaifa, which is a fitting name for this one.

There are two ways forward:

  • You create it (fastest): go to github.com/new and make an empty public repo named haunted-shell, without a README or license. Tell me when it's there and I'll push over SSH and put the link in your post.
  • Install gh: I run brew install gh, then you sign in yourself with gh auth login, since I can't enter your login. After that I create and push the repo.

POST.md is kept out of the repo (it's in .gitignore), so your draft and its TODOs won't be public.

You

changes pushed

Agent

Verified: a6cb435 is on GitHub. Now putting the repo link into the post and the README clone command.

Your push worked: commit a6cb435 is on https://github.com/ghost-huzaifa/haunted-shell.

I then pushed one more small commit (44af410) so the README's install command has the real clone URL. POST.md now embeds the repo ({% github ghost-huzaifa/haunted-shell %}) and stays only on your machine.

What's left before Oct 31, 11:59 PM PDT:
1. Record a 20–30s terminal GIF and put it in the post's Demo section.
2. Paste the draft into DEV's submission template and fill the remaining TODOs: the official template, the paragraph on how you built it, and the optional DevRelay link.
3. Publish.

You

I have recorded it, how to convert it to GIF?

Agent

I couldn't find your recording. The only video changed in the last day under Desktop, Movies, Downloads or Documents is a movie, not a screen capture. ffmpeg also isn't installed. Here's how to do it yourself.

1. Install ffmpeg (once):
bash
brew install ffmpeg

2. Convert. Replace demo.mov with your file's path; you can drag the file into the terminal to paste it:
bash
ffmpeg -i demo.mov -vf "fps=12,scale=900:-1:flags=lanczos,split[a][b];[a]palettegen=max_colors=128[p];[b][p]paletteuse=dither=bayer" -loop 0 demo.gif

  • fps=12 and scale=900 keep the file small. The text stays crisp because most of the screen is a dark terminal background.
  • The palette step stops the violet ghost text from getting blotchy.
  • If the result is over about 10 MB, lower fps=12 to 8 or scale=900 to 720.
  • To cut out dead time, add -ss 2 -t 25 before -i. This starts 2s in and keeps 25s.

3. Add it to DEV: in the post editor, use the image upload button and place the GIF under ## Demo.

If you tell me where the recording is and say yes to installing ffmpeg, I'll do the conversion.

You

Install the DevRelay MCP server and skills on this machine. Check which OS you are running on: on macOS or Linux, run curl -fsSL https://devrelay.com/install.sh | sh in the shell; on Windows, run iwr -useb https://devrelay.com/install.ps1 | iex in PowerShell. Once the installer finishes, confirm that DevRelay and its skills are registered.

Agent

On macOS, so the shell installer applies. I'll download the script and read it before running it.

The installer comes from MLH's DevRelay project, which matches the challenge page. Before running it, I'm checking how it downloads the binary and how it registers with Claude Code.

Installation succeeded. Now checking for myself that Claude Code has the server and skills registered.

DevRelay is installed on this Mac (macOS, so I used the shell installer), and it's registered with Claude Code. Before running it I downloaded the script and read it, then ran that same copy.

Confirmed registered:
- MCP server: ~/.claude.json has devrelay-gateway, pointing to ~/.devrelay/bin/dev_mlh_mcp_server --stdio. The binary reports version devrelay 0.1.27.
- Skills: 9 devrelay-* skills are in ~/.claude/skills, including devrelay-sessions, devrelay-publishing and devrelay-challenges. They already show up in this session.
- Other tools it set up: Claude Desktop, Cursor, Codex, Gemini CLI, Antigravity and GitHub Copilot CLI each got the MCP server, and most also got the skills.

What else it changed:
- A daily auto-update job at ~/Library/LaunchAgents/com.devrelay.updater.plist.
- ~/.devrelay/bin added to your shell PATH.
- Basic telemetry is on. Turn it off with devrelay telemetry off.

Not done:
- It couldn't register with Zed or VS Code's GitHub Copilot, even though their config files exist. I left those alone.
- Its health check says no update check has run yet, which is normal right after an install.

Two steps only you can do:
1. Sign in with your DEV or MLH account. This opens your browser:
bash
~/.devrelay/bin/devrelay login

2. Restart the Claude app so it picks up the new MCP server. Until then the DevRelay tools aren't available here.

After that, the devrelay-sessions skill can save this session so you can embed it in your post. That fills the optional DevRelay TODO in POST.md.

You

<bash-input>~/.devrelay/bin/devrelay login</bash-input><bash-stdout>Sign in with either the email you associate with MLH or your DEV (dev.to) account.
Waiting for authorization in your browser. If it did not open, visit:
https://www.mlh.com/oauth/authorize?response_type=code&amp;client_id=devrelay-native&amp;redirect_uri=http%3A%2F%2Flocalhost%3A8765%2Fcallback&amp;scope=public+offline_access+user%3Aread%3Aprofile+user%3Aread%3Aeducation+user%3Aread%3Aemployment+dev%3Aread%3Aall+dev%3Awrite%3Aall+participant%3Aread%3Aevent+participant%3Aread%3Apromo_code+participant%3Awrite%3Apromo_code+participant%3Aread%3Aproject+participant%3Awrite%3Aproject+participant%3Awrite%3Aparticipation&amp;state=<redacted>&code_challenge=<redacted>&code_challenge_method=S256
If the browser cannot reach the callback, paste the full callback URL here and press Enter:
Connected: Muhammad Huzaifa (MLH user <id>)

╔══════════════════════════════════════════════════════════════════════════╗
║ 🎉 ✓ You're signed in and DevRelay is ready! ║
╚══════════════════════════════════════════════════════════════════════════╝
┃ MLH account: Muhammad Huzaifa (MLH user <id>)
┃ DEV account: @muhammadhuzaifa2955 (connected on dev.to)
┃ MLH event: Hacktoberfest 2026 (registered)

✨ What you might do next in your coding agent:
• You're registered for Hacktoberfest 2026 (in progress)! Ask your agent:
▸ "Show me the prize categories, sponsor offers, and Agent Skills for Hacktoberfest 2026."
▸ "Help me prepare and submit my hackathon project to Hacktoberfest 2026."
• With your DEV (@muhammadhuzaifa2955) account connected, ask your agent:
▸ "What does the DEV community recommend for this library or bug?"
▸ "Stage a draft post on DEV about what we're building."
• Live DEV Challenge right now: "Hacktoberfest Open-Source AI Challenge: Week 4" — ask your agent:
▸ "Audit my project against the Hacktoberfest Open-Source AI Challenge: Week 4 rubric and help me draft an entry."

┌──────────────────────────────────────────────────────────────────────────┐
│ 🔄 Restart your AI host so it picks up the new MCP server. │
└──────────────────────────────────────────────────────────────────────────┘</bash-stdout><bash-stderr></bash-stderr>

Agent

Sign-in worked. Your MLH account, DEV account @muhammadhuzaifa2955 and Hacktoberfest 2026 registration are all connected.

I can't use DevRelay's tools in this session yet; I checked and none are loaded. Restart the Claude app so it loads the MCP server, then come back to this session.

After the restart I can:
1. Save this session to DEV and embed it in your post. That fills the DevRelay TODO.
2. Stage POST.md as an unpublished draft on your DEV account, so you only add the GIF and publish.

The deadline is Oct 31, 11:59 PM PDT.

You

<pasted_content id="f022">
macbookpro@MacBooks-MacBook-Pro haunted-shell % ffmpeg -i ~/Screenshots/Screen\ Recording\ 2026-10-30\ at\ 10.43.23 PM.mov at 10.43.23 PM.mov -vf "fps=12,scale=900👎flags=lanczos,split[a][b];[a]palettegen=max_colors=128[p];[b][p]paletteuse=dither=bayer" -loop 0 demo.gif
ffmpeg version 9.0.2 Copyright (c) 2000-2026 the FFmpeg developers
built with Apple clang version 21.0.0 (clang-2100.3.34.2)
configuration: --prefix=/opt/homebrew/Cellar/ffmpeg/9.0.2_1 --enable-shared --enable-pthreads --enable-version3 --cc=clang --host-cflags= --host-ldflags= --enable-ffplay --enable-gpl --enable-libsvtav1 --enable-libopus --enable-libx264 --enable-libmp3lame --enable-libdav1d --enable-libvmaf --enable-libvpx --enable-libx265 --enable-openssl --enable-videotoolbox --enable-audiotoolbox --enable-neon
libavutil 61. 1.102 / 61. 1.102
libavcodec 63. 1.102 / 63. 1.102
libavformat 63. 1.102 / 63. 1.102
libavdevice 63. 1.102 / 63. 1.102
libavfilter 12. 1.102 / 12. 1.102
libswscale 10. 1.102 / 10. 1.102
libswresample 7. 1.102 / 7. 1.102
Input #0, mov,mp4,m4a,3gp,3g2,mj2, from '~/Screenshots/Screen Recording 2026-10-30 at 10.43.23 PM.mov':
Metadata:
major_brand : qt

minor_version : 0
compatible_brands: qt

creation_time : 2026-10-30T17:43:25.000000Z
com.apple.quicktime.author: ReplayKitRecording
com.apple.quicktime.full-frame-rate-playback-intent: 1
Duration: 00:00:40.64, start: 0.000000, bitrate: 4105 kb/s
Stream #0:00x1: Audio: aac (mp4a / 0x6134706D), 48000 Hz, stereo, fltp, 2 kb/s, start 0.191521 (default)
Metadata:
creation_time : 2026-10-30T17:43:25.000000Z
handler_name : Core Media Audio
vendor_id : [0][0][0][0]
Stream #0:10x2: Video: h264 (Main) (avc1 / 0x31637661), yuv420p(tv, bt709, progressive), 3340x2104, 4409 kb/s, 8.60 fps, 60 tbr, 600 tbn (default)
Metadata:
creation_time : 2026-10-30T17:43:25.000000Z
handler_name : Core Media Video
vendor_id : [0][0][0][0]
encoder : H.264
[AVFormatContext @ 0x7b57023980] Unable to choose an output format for 'at'; use a standard extension for the filename or specify the format manually.
[out#0 @ 0x7b570243c0] Error initializing the muxer for at: Invalid argument
Error opening output file at.
Error opening output files: Invalid argument
</pasted_content id="f022">

Agent

The command broke because at 10.43.23 PM.mov appears twice; tab completion probably pasted it again. ffmpeg read that leftover at as the output filename. I'll run the conversion on your recording directly.

macOS puts a special narrow space before "PM" in screenshot filenames. Matching the file with a wildcard instead:

2.1 MB, which is fine for DEV. Checking a frame so I know the ghost text is readable:

Now it's readable, and that's a great whisper. Rendering the final GIF from seconds 3 to 31, where both whispers appear:

The GIF ends on an already-cleared screen, so the loop would end on nothing. I'll find the exact second the screen clears so it ends on the last whisper instead:

It's cut off mid-sentence, because seeking in this variable-frame-rate recording isn't exact. I'll extend the end and check again:

Ending at 0:28.5 catches the complete whisper. I'll also hold that last frame for 2.5 seconds so viewers can read it before the loop restarts:

The GIF is done: 28 seconds, 667 KB, and it ends on the full "stuck in a loop" whisper. I'll put a caption into the post's Demo section:

The GIF is ready at ~/dev/haunted-shell/demo.gif: 28 seconds and 667 KB.

Why your command failed: the filename was there twice, so ffmpeg took at as the output file. On a second try, there was another catch: macOS writes a special narrow space before "PM" in screenshot names. A * wildcard in the path avoids both problems.

Changes from the plain conversion:
- Cropped to the terminal. In the full-screen capture the dim violet whispers were too small to read.
- Brightened a little so the whispers stand out against the dark background.
- Trimmed from 0:03 to 0:28.5, which drops the idle start and the clear at the end.
- The last frame holds for 2.5 seconds so viewers can read it before the loop restarts.

It shows git status failing, then "your git repo is dead, but i'm still here. i never learned to push." Then cd, and "why does it feel like we're both stuck in a loop?", which suits a GIF that loops. I added that as a caption in POST.md's Demo section.

To add it to the post, use the image upload in the DEV editor and replace the TODO line under Demo with the image it gives you.

The Spirit of It

While I was recording the demo, I cd'd into my day-job repo, and the ghost whispered: "you're still here. i'm still here. why does it feel like we're both stuck in a loop?" An 8B model running on my laptop, with no internet, called me out. Happy Halloween. 🕯️

Top comments (0)