Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
b034d71
feat(irc): IRC server M1 — wire-protocol listener with always-on defa…
mwunsch Apr 30, 2026
a424c77
fix(node): EGGHEAD_SERVER is an absolute directive, never falls through
mwunsch Apr 30, 2026
a51cc41
feat(irc): M2 — agents act, not just speak
mwunsch Apr 30, 2026
69779c7
feat(irc): mark the default room in LIST and add #default JOIN alias
mwunsch Apr 30, 2026
35a09de
fix(irc): make #default alias transparent + honest user counts in LIST
mwunsch Apr 30, 2026
22712ca
fix(irc): LIST with empty trailing param (`LIST :`) returns all rooms
mwunsch Apr 30, 2026
d811104
feat(irc): M3 — slash-command palette as IRC verbs, synthesized topic…
mwunsch Apr 30, 2026
6f78321
feat(irc): M3.5 — KICK, INVITE, WHOIS, MOTD, VERSION, TIME
mwunsch Apr 30, 2026
86dd521
fix(irc): WHOIS metadata + INVITE #default — two real bugs from live use
mwunsch Apr 30, 2026
66ae9ce
fix(irc): WHOIS — drop disposition (it's the system prompt body), sur…
mwunsch Apr 30, 2026
7baa54c
feat(irc): M3.6 — DM as ephemeral Egghead.prompt/3
mwunsch May 1, 2026
3640beb
fix(irc): server-side PING keepalive so idle clients don't drop
mwunsch May 1, 2026
1c93768
feat(irc): surface agent tool denials as CTCP ACTION
mwunsch May 1, 2026
e03b187
feat(irc): IRCv3 server-time + scrollback replay on JOIN, sweep miles…
mwunsch May 1, 2026
397f7d5
feat(irc): CHATHISTORY (LATEST/BEFORE/AFTER/AROUND/BETWEEN) + PONG si…
mwunsch May 1, 2026
df87254
fix(irc): replay scrollback unconditionally + connection-lifecycle lo…
mwunsch May 1, 2026
6a58ecb
fix(irc): disable Thousand Island's 60s read_timeout — that's the dis…
mwunsch May 1, 2026
1b911e7
revert(irc): re-gate JOIN history replay on server-time cap
mwunsch May 1, 2026
f03cf8d
chore(irc): downgrade PING/PONG to debug + richer correlation data
mwunsch May 1, 2026
5100095
refactor(irc): extract StreamBuffer, Format, Channels, ChatHistory
mwunsch May 1, 2026
0284761
refactor(irc): extract Forwarder, Whois, Wire, Verbs, Agents
mwunsch May 1, 2026
763e1df
docs(site): add IRC server guide under Integrations
mwunsch May 1, 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
1 change: 1 addition & 0 deletions config/test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Config
# Don't auto-start the RecordStore in tests — each test starts its own.
config :egghead, :start_record_store, false
config :egghead, :start_web, false
config :egghead, :start_irc, false

# Quiet logs in tests — only warnings and errors
config :logger, level: :warning
Expand Down
74 changes: 65 additions & 9 deletions lib/egghead/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,20 @@ defmodule Egghead.Application do
All runtime configuration is loaded here in `apply_config/0`.
No runtime.exs. Sources in precedence order:

1. Environment variables (EGGHEAD_RECORDS, PORT, etc.)
1. Environment variables (EGGHEAD_RECORDS, PORT, EGGHEAD_IRC_PORT, etc.)
2. Config file (~/.config/egghead/config.yml)
3. Compile-time defaults (config/config.exs)

In release mode (Burrito binary), `configure_for_command/1` parses
argv BEFORE the supervision tree to determine what to start:

| Command | Record store | Web | Log mode |
|---------|-------------|-----|----------|
| (none) / tui | yes | no | :file |
| serve | yes | yes | :console |
| mcp | yes | no | :silent |
| agent list, llm models, doctor, init | yes | no | :silent |
| --help, --version, config, llm list, logs | no | no | :silent |
| Command | Record store | Web | IRC | Log mode |
|---------|-------------|-----|-----|----------|
| (none) / tui | yes | no | no | :file |
| serve | yes | yes | yes | :console |
| mcp | yes | no | no | :silent |
| agent list, llm models, doctor, init | yes | no | no | :silent |
| --help, --version, config, llm list, logs | no | no | no | :silent |
"""

use Application
Expand Down Expand Up @@ -73,7 +73,7 @@ defmodule Egghead.Application do
{Registry, keys: :unique, name: Egghead.Doc.Registry},
{Egghead.Doc.Supervisor, []},
Egghead.TUI.MarkdownCache
] ++ web_children()
] ++ web_children() ++ irc_children()

# Commands that don't need the app (--help, config, etc.)
true ->
Expand Down Expand Up @@ -163,14 +163,17 @@ defmodule Egghead.Application do
nil ->
# Default = TUI
Application.put_env(:egghead, :start_web, false)
Application.put_env(:egghead, :start_irc, false)
Application.put_env(:egghead, :log_mode, :file)

"tui" ->
Application.put_env(:egghead, :start_web, false)
Application.put_env(:egghead, :start_irc, false)
Application.put_env(:egghead, :log_mode, :file)

_ ->
Application.put_env(:egghead, :start_web, false)
Application.put_env(:egghead, :start_irc, false)
Application.put_env(:egghead, :log_mode, :silent)
end
end
Expand Down Expand Up @@ -239,6 +242,11 @@ defmodule Egghead.Application do
Application.put_env(:egghead, Egghead.Web.Endpoint, endpoint_config)

{:error, _} ->
# No config file — register a default Config struct so callers
# that read `Application.get_env(:egghead, :config)` (notably
# the IRC supervisor) still see the same default-bearing shape
# as a successfully-loaded config.
Application.put_env(:egghead, :config, %Egghead.Config{})
Application.put_env(:egghead, :records_dir, Path.expand("~/.egghead"))

current = Application.get_env(:egghead, Egghead.Web.Endpoint, [])
Expand Down Expand Up @@ -293,6 +301,34 @@ defmodule Egghead.Application do
Keyword.put(current, :http, Keyword.put(http, :ip, {0, 0, 0, 0}))
)
end

# IRC env overrides — mirror PORT / EGGHEAD_BIND / EGGHEAD_WEB but
# name-spaced so they don't collide with web. Mutate the loaded
# `:config` struct in place (the IRC supervisor reads from there).
if System.get_env("EGGHEAD_IRC") == "false" do
Application.put_env(:egghead, :start_irc, false)
end

if port_str = System.get_env("EGGHEAD_IRC_PORT") do
put_irc(:port, String.to_integer(port_str))
end

if System.get_env("EGGHEAD_IRC_BIND") == "0.0.0.0" do
put_irc(:bind, "0.0.0.0")
end
end

# Update one field of the IRC config map in-place. The IRC supervisor
# reads `Application.get_env(:egghead, :config).irc` at boot.
defp put_irc(key, value) do
case Application.get_env(:egghead, :config) do
%Egghead.Config{} = cfg ->
irc = Map.put(cfg.irc || %{}, key, value)
Application.put_env(:egghead, :config, %{cfg | irc: irc})

_ ->
:ok
end
end

# --- Theme ---
Expand Down Expand Up @@ -401,6 +437,26 @@ defmodule Egghead.Application do
end
end

defp irc_children do
if Application.get_env(:egghead, :start_irc, true) do
[{Egghead.IRC.Server, config: irc_config()}]
else
[]
end
end

# Always returns a map — the Config struct's `:irc` field defaults
# to a populated map, and the no-config-file branch in apply_config/0
# registers a default `%Egghead.Config{}`. If a downstream caller
# somehow blanked the field, fall back to the same defaults so the
# IRC supervisor never sees nil.
defp irc_config do
case Application.get_env(:egghead, :config) do
%{irc: cfg} when is_map(cfg) -> cfg
_ -> %{port: 6667, bind: "127.0.0.1", hostname: nil, password: nil}
end
end

defp release_mode? do
Burrito.Util.running_standalone?()
end
Expand Down
79 changes: 71 additions & 8 deletions lib/egghead/cli/serve.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,31 @@ defmodule Egghead.CLI.Serve do
egghead serve [flags]

DESCRIPTION
Start the web server and MCP HTTP endpoint. The MCP endpoint is
available at /mcp on the same port. Logs go to stdout.
Start the web server (with the MCP HTTP endpoint at /mcp) and the
IRC server. Both run in the same supervision tree on sensible
defaults — no config required. Logs go to stdout.

For the MCP stdio transport (editor integration), use `egghead mcp`.

FLAGS
--port <n> Override the HTTP port (default: 4000)
--config PATH Override config file location
-h, --help Show this help
--port <n> Override the HTTP port (default: 4000)
--irc-port <n> Override the IRC port (default: 6667)
--no-web Skip starting the web / MCP-HTTP endpoint
--no-irc Skip starting the IRC server
--config PATH Override config file location
-h, --help Show this help

ENVIRONMENT
PORT, EGGHEAD_HOST, EGGHEAD_BIND, EGGHEAD_WEB=false
EGGHEAD_IRC_PORT, EGGHEAD_IRC_BIND, EGGHEAD_IRC=false
EGGHEAD_IRC_PASSWORD (if `irc.password: "{env:...}"` is configured)

EXAMPLES
$ egghead serve
$ egghead serve --port 8080
$ egghead serve --irc-port 6697
$ egghead serve --no-web # IRC-only
$ egghead serve --no-irc # web-only

SEE ALSO
egghead mcp, egghead config, egghead doctor
Expand All @@ -33,7 +45,12 @@ defmodule Egghead.CLI.Serve do
defp do_run(args) do
{opts, _, _} =
OptionParser.parse(args,
switches: [port: :integer],
switches: [
port: :integer,
irc_port: :integer,
no_web: :boolean,
no_irc: :boolean
],
aliases: []
)

Expand All @@ -49,6 +66,14 @@ defmodule Egghead.CLI.Serve do
)
end

# IRC port overrides have to land on the loaded Config struct because
# the IRC supervisor reads from there at boot. CLI flag wins over env
# var wins over config file (CLI runs after apply_config in start_app).
if irc_port = opts[:irc_port], do: put_irc_field(:port, irc_port)

if opts[:no_web], do: Application.put_env(:egghead, :start_web, false)
if opts[:no_irc], do: Application.put_env(:egghead, :start_irc, false)

Egghead.CLI.start_app(:console)

if Egghead.Node.connected?() do
Expand All @@ -58,8 +83,16 @@ defmodule Egghead.CLI.Serve do
end

port = get_port()
IO.puts("Egghead running on http://localhost:#{port}")
IO.puts("MCP endpoint at http://localhost:#{port}/mcp")

if Application.get_env(:egghead, :start_web, true) do
IO.puts("Egghead web on http://localhost:#{port}")
IO.puts("MCP endpoint at http://localhost:#{port}/mcp")
end

case irc_listening_on() do
nil -> :ok
{host, irc_port} -> IO.puts("Egghead IRC on irc://#{host}:#{irc_port}")
end

if node() != :nonode@nohost do
IO.puts("Node: #{node()}")
Expand All @@ -79,6 +112,36 @@ defmodule Egghead.CLI.Serve do
Keyword.get(http, :port, 4000)
end

defp irc_listening_on do
cond do
Application.get_env(:egghead, :start_irc, true) == false ->
nil

Process.whereis(Egghead.IRC.Server) == nil ->
nil

true ->
cfg =
case Application.get_env(:egghead, :config) do
%{irc: %{} = irc} -> irc
_ -> %{}
end

{Map.get(cfg, :bind, "127.0.0.1"), Map.get(cfg, :port, 6667)}
end
end

defp put_irc_field(key, value) do
case Application.get_env(:egghead, :config) do
%Egghead.Config{} = cfg ->
irc = Map.put(cfg.irc || %{}, key, value)
Application.put_env(:egghead, :config, %{cfg | irc: irc})

_ ->
:ok
end
end

# Show how a peer host should attach when this server is reachable
# off-box. We only print the hint when `server.host` is configured —
# that's the explicit signal that the operator wants cross-host
Expand Down
70 changes: 67 additions & 3 deletions lib/egghead/config.ex
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,17 @@ defmodule Egghead.Config do
port: 4000
host: localhost
bind: 127.0.0.1

irc:
port: 6667
bind: 127.0.0.1
hostname: irc.local # optional; defaults to gethostname()
password: "{env:EGGHEAD_IRC_PASSWORD}" # optional shared password

Both `web:` and `irc:` are fully optional — both servers start with the
defaults shown above. To disable a server, set `EGGHEAD_WEB=false` or
`EGGHEAD_IRC=false` in the environment, or pass `--no-web` / `--no-irc`
to `egghead serve`.
"""

defstruct records_dir: "~/.egghead",
Expand All @@ -39,7 +50,8 @@ defmodule Egghead.Config do
theme: "terminal-dark",
web: %{port: 4000, host: "localhost", bind: "127.0.0.1"},
mcp_servers: [],
server: nil
server: nil,
irc: %{port: 6667, bind: "127.0.0.1", hostname: nil, password: nil}

@type llm_entry :: %{
provider: String.t(),
Expand All @@ -58,6 +70,13 @@ defmodule Egghead.Config do
requires: [Egghead.Capability.Grant.t()]
}

@type irc_config :: %{
port: non_neg_integer(),
bind: String.t(),
hostname: String.t() | nil,
password: String.t() | nil
}

@type t :: %__MODULE__{
records_dir: String.t(),
skills_dir: String.t(),
Expand All @@ -67,7 +86,8 @@ defmodule Egghead.Config do
default_room: String.t() | nil,
theme: String.t(),
web: %{port: non_neg_integer(), host: String.t(), bind: String.t()},
mcp_servers: [mcp_server()]
mcp_servers: [mcp_server()],
irc: irc_config()
}

# --- Paths ---
Expand Down Expand Up @@ -277,10 +297,34 @@ defmodule Egghead.Config do
theme: data["theme"] || "terminal-dark",
web: parse_web(data["web"]),
mcp_servers: parse_mcp_servers(data["mcp_servers"]),
server: parse_server(data["server"])
server: parse_server(data["server"]),
irc: parse_irc(data["irc"])
}
end

defp parse_irc(nil), do: default_irc()

defp parse_irc(map) when is_map(map) do
%{
port: int(map["port"], 6667),
bind: str(map["bind"], "127.0.0.1"),
hostname: str(map["hostname"], nil),
password: resolve_value(recover_env_ref(map["password"]))
}
end

defp parse_irc(_), do: default_irc()

defp default_irc do
%{port: 6667, bind: "127.0.0.1", hostname: nil, password: nil}
end

defp int(v, _default) when is_integer(v), do: v
defp int(_, default), do: default

defp str(v, _default) when is_binary(v) and v != "", do: v
defp str(_, default), do: default

defp parse_mcp_servers(nil), do: []

defp parse_mcp_servers(list) when is_list(list) do
Expand Down Expand Up @@ -416,6 +460,7 @@ defmodule Egghead.Config do
emit_field("default_room", config.default_room),
emit_field("theme", config.theme),
emit_web(config.web),
emit_irc(config.irc),
emit_mcp_servers(config.mcp_servers)
]

Expand Down Expand Up @@ -532,6 +577,25 @@ defmodule Egghead.Config do
if length(lines) > 1, do: Enum.join(lines, "\n"), else: nil
end

defp emit_irc(%{port: 6667, bind: "127.0.0.1", hostname: nil, password: nil}), do: nil
defp emit_irc(nil), do: nil

defp emit_irc(irc) do
lines = ["irc:"]
lines = if irc[:port] && irc.port != 6667, do: lines ++ [" port: #{irc.port}"], else: lines

lines =
if irc[:bind] && irc.bind != "127.0.0.1", do: lines ++ [" bind: #{irc.bind}"], else: lines

lines =
if irc[:hostname], do: lines ++ [" hostname: #{yaml_escape(irc.hostname)}"], else: lines

lines =
if irc[:password], do: lines ++ [" password: #{yaml_escape(irc.password)}"], else: lines

if length(lines) > 1, do: Enum.join(lines, "\n"), else: nil
end

# If YAML parsed {env:VAR} as a map %{"env:VAR" => nil}, recover the string form
defp recover_env_ref(value) when is_map(value) do
case Map.keys(value) do
Expand Down
Loading
Loading