|
0adbd1e…
|
lmata
|
1 |
# Adding a New Agent Runtime |
|
0adbd1e…
|
lmata
|
2 |
|
|
0adbd1e…
|
lmata
|
3 |
This guide explains how to add a new agent runtime — a coding assistant, automation tool, or any interactive terminal process — to the scuttlebot relay ecosystem. |
|
0adbd1e…
|
lmata
|
4 |
|
|
0adbd1e…
|
lmata
|
5 |
The relay ecosystem has two shapes. Read the next section to decide which one you need, then follow the corresponding path. |
|
0adbd1e…
|
lmata
|
6 |
|
|
0adbd1e…
|
lmata
|
7 |
--- |
|
0adbd1e…
|
lmata
|
8 |
|
|
0adbd1e…
|
lmata
|
9 |
## Relay broker vs. IRC-resident agent |
|
0adbd1e…
|
lmata
|
10 |
|
|
0adbd1e…
|
lmata
|
11 |
**Use a relay broker** when: |
|
0adbd1e…
|
lmata
|
12 |
|
|
0adbd1e…
|
lmata
|
13 |
- The runtime is an interactive terminal session (Claude Code, Codex, Gemini CLI, etc.) |
|
0adbd1e…
|
lmata
|
14 |
- Sessions are ephemeral — they start and stop with each coding task |
|
0adbd1e…
|
lmata
|
15 |
- You want per-session presence (`online`/`offline`) and per-session operator instructions |
|
0adbd1e…
|
lmata
|
16 |
- The runtime exposes a session log, hook points, or a PTY you can wrap |
|
0adbd1e…
|
lmata
|
17 |
|
|
0adbd1e…
|
lmata
|
18 |
**Use an IRC-resident agent** when: |
|
0adbd1e…
|
lmata
|
19 |
|
|
0adbd1e…
|
lmata
|
20 |
- The process should run indefinitely (a moderator, an event router, a summarizer) |
|
0adbd1e…
|
lmata
|
21 |
- Presence and identity are permanent, not per-session |
|
0adbd1e…
|
lmata
|
22 |
- You are building a new system bot in the style of `oracle`, `warden`, or `herald` |
|
0adbd1e…
|
lmata
|
23 |
|
|
0adbd1e…
|
lmata
|
24 |
For IRC-resident agents, use `pkg/ircagent/` as your foundation and follow the system bot pattern in `internal/bots/`. This guide focuses on the **relay broker** pattern. |
|
0adbd1e…
|
lmata
|
25 |
|
|
0adbd1e…
|
lmata
|
26 |
--- |
|
0adbd1e…
|
lmata
|
27 |
|
|
0adbd1e…
|
lmata
|
28 |
## Canonical repo layout |
|
0adbd1e…
|
lmata
|
29 |
|
|
0adbd1e…
|
lmata
|
30 |
Every terminal broker follows this layout: |
|
0adbd1e…
|
lmata
|
31 |
|
|
0adbd1e…
|
lmata
|
32 |
``` |
|
0adbd1e…
|
lmata
|
33 |
cmd/{runtime}-relay/ |
|
0adbd1e…
|
lmata
|
34 |
main.go broker entrypoint |
|
0adbd1e…
|
lmata
|
35 |
skills/{runtime}-relay/ |
|
0adbd1e…
|
lmata
|
36 |
install.md human install primer |
|
0adbd1e…
|
lmata
|
37 |
FLEET.md rollout and operations guide |
|
0adbd1e…
|
lmata
|
38 |
hooks/ |
|
0adbd1e…
|
lmata
|
39 |
README.md runtime-specific hook contract |
|
0adbd1e…
|
lmata
|
40 |
scuttlebot-check.sh pre-action hook (check IRC for instructions) |
|
0adbd1e…
|
lmata
|
41 |
scuttlebot-post.sh post-action hook (post tool activity to IRC) |
|
0adbd1e…
|
lmata
|
42 |
scripts/ |
|
0adbd1e…
|
lmata
|
43 |
install-{runtime}-relay.sh tracked installer |
|
0adbd1e…
|
lmata
|
44 |
pkg/sessionrelay/ shared transport (do not copy; import) |
|
0adbd1e…
|
lmata
|
45 |
``` |
|
0adbd1e…
|
lmata
|
46 |
|
|
0adbd1e…
|
lmata
|
47 |
Files installed into `~/.{runtime}/`, `~/.local/bin/`, or `~/.config/` are **copies**. The repo is the source of truth. |
|
0adbd1e…
|
lmata
|
48 |
|
|
0adbd1e…
|
lmata
|
49 |
--- |
|
0adbd1e…
|
lmata
|
50 |
|
|
0adbd1e…
|
lmata
|
51 |
## Step-by-step: implementing the broker |
|
0adbd1e…
|
lmata
|
52 |
|
|
0adbd1e…
|
lmata
|
53 |
### 1. Start from `pkg/sessionrelay` |
|
0adbd1e…
|
lmata
|
54 |
|
|
0adbd1e…
|
lmata
|
55 |
`pkg/sessionrelay` provides the `Connector` interface and two implementations: |
|
0adbd1e…
|
lmata
|
56 |
|
|
0adbd1e…
|
lmata
|
57 |
```go |
|
0adbd1e…
|
lmata
|
58 |
type Connector interface { |
|
0adbd1e…
|
lmata
|
59 |
Connect(ctx context.Context) error |
|
0adbd1e…
|
lmata
|
60 |
Post(ctx context.Context, text string) error |
|
0adbd1e…
|
lmata
|
61 |
MessagesSince(ctx context.Context, since time.Time) ([]Message, error) |
|
0adbd1e…
|
lmata
|
62 |
Touch(ctx context.Context) error |
|
0adbd1e…
|
lmata
|
63 |
Close(ctx context.Context) error |
|
0adbd1e…
|
lmata
|
64 |
} |
|
0adbd1e…
|
lmata
|
65 |
``` |
|
0adbd1e…
|
lmata
|
66 |
|
|
0adbd1e…
|
lmata
|
67 |
Instantiate with: |
|
0adbd1e…
|
lmata
|
68 |
|
|
0adbd1e…
|
lmata
|
69 |
```go |
|
0adbd1e…
|
lmata
|
70 |
conn, err := sessionrelay.New(sessionrelay.Config{ |
|
0adbd1e…
|
lmata
|
71 |
Transport: sessionrelay.TransportIRC, // or TransportHTTP |
|
0adbd1e…
|
lmata
|
72 |
URL: cfg.URL, |
|
0adbd1e…
|
lmata
|
73 |
Token: cfg.Token, |
|
0adbd1e…
|
lmata
|
74 |
Channel: cfg.Channel, |
|
0adbd1e…
|
lmata
|
75 |
Nick: cfg.Nick, |
|
0adbd1e…
|
lmata
|
76 |
IRC: sessionrelay.IRCConfig{ |
|
0adbd1e…
|
lmata
|
77 |
Addr: cfg.IRCAddr, |
|
0adbd1e…
|
lmata
|
78 |
Pass: cfg.IRCPass, |
|
0adbd1e…
|
lmata
|
79 |
AgentType: "worker", |
|
0adbd1e…
|
lmata
|
80 |
DeleteOnClose: cfg.IRCDeleteOnClose, |
|
0adbd1e…
|
lmata
|
81 |
}, |
|
0adbd1e…
|
lmata
|
82 |
}) |
|
0adbd1e…
|
lmata
|
83 |
``` |
|
0adbd1e…
|
lmata
|
84 |
|
|
0adbd1e…
|
lmata
|
85 |
`TransportHTTP` routes all posts through the bridge bot (`POST /v1/channels/{ch}/messages`). `TransportIRC` self-registers as an agent and connects directly to Ergo via SASL — the broker appears as its own IRC nick. |
|
0adbd1e…
|
lmata
|
86 |
|
|
0adbd1e…
|
lmata
|
87 |
### 2. Define your config struct |
|
0adbd1e…
|
lmata
|
88 |
|
|
0adbd1e…
|
lmata
|
89 |
```go |
|
0adbd1e…
|
lmata
|
90 |
type config struct { |
|
0adbd1e…
|
lmata
|
91 |
// Required |
|
0adbd1e…
|
lmata
|
92 |
URL string |
|
0adbd1e…
|
lmata
|
93 |
Token string |
|
0adbd1e…
|
lmata
|
94 |
Channel string |
|
0adbd1e…
|
lmata
|
95 |
Nick string |
|
0adbd1e…
|
lmata
|
96 |
|
|
0adbd1e…
|
lmata
|
97 |
// Transport |
|
0adbd1e…
|
lmata
|
98 |
Transport sessionrelay.Transport |
|
0adbd1e…
|
lmata
|
99 |
IRCAddr string |
|
0adbd1e…
|
lmata
|
100 |
IRCPass string |
|
0adbd1e…
|
lmata
|
101 |
IRCDeleteOnClose bool |
|
0adbd1e…
|
lmata
|
102 |
|
|
0adbd1e…
|
lmata
|
103 |
// Tuning |
|
0adbd1e…
|
lmata
|
104 |
PollInterval time.Duration |
|
0adbd1e…
|
lmata
|
105 |
HeartbeatInterval time.Duration |
|
0adbd1e…
|
lmata
|
106 |
InterruptOnMessage bool |
|
0adbd1e…
|
lmata
|
107 |
HooksEnabled bool |
|
0adbd1e…
|
lmata
|
108 |
|
|
0adbd1e…
|
lmata
|
109 |
// Runtime-specific |
|
0adbd1e…
|
lmata
|
110 |
RuntimeBin string |
|
0adbd1e…
|
lmata
|
111 |
Args []string |
|
0adbd1e…
|
lmata
|
112 |
TargetCWD string |
|
0adbd1e…
|
lmata
|
113 |
} |
|
0adbd1e…
|
lmata
|
114 |
``` |
|
0adbd1e…
|
lmata
|
115 |
|
|
0adbd1e…
|
lmata
|
116 |
### 3. Implement `loadConfig` |
|
0adbd1e…
|
lmata
|
117 |
|
|
0adbd1e…
|
lmata
|
118 |
Read from environment variables, then from a shared env file (`~/.config/scuttlebot-relay.env`), then apply defaults: |
|
0adbd1e…
|
lmata
|
119 |
|
|
0adbd1e…
|
lmata
|
120 |
```go |
|
0adbd1e…
|
lmata
|
121 |
func loadConfig() config { |
|
0adbd1e…
|
lmata
|
122 |
cfgFile := envOr("SCUTTLEBOT_CONFIG_FILE", |
|
0adbd1e…
|
lmata
|
123 |
filepath.Join(os.Getenv("HOME"), ".config/scuttlebot-relay.env")) |
|
0adbd1e…
|
lmata
|
124 |
loadEnvFile(cfgFile) |
|
0adbd1e…
|
lmata
|
125 |
|
|
0adbd1e…
|
lmata
|
126 |
transport := sessionrelay.Transport(envOr("SCUTTLEBOT_TRANSPORT", "irc")) |
|
0adbd1e…
|
lmata
|
127 |
|
|
0adbd1e…
|
lmata
|
128 |
return config{ |
|
0adbd1e…
|
lmata
|
129 |
URL: envOr("SCUTTLEBOT_URL", "http://localhost:8080"), |
|
0adbd1e…
|
lmata
|
130 |
Token: os.Getenv("SCUTTLEBOT_TOKEN"), |
|
0adbd1e…
|
lmata
|
131 |
Channel: envOr("SCUTTLEBOT_CHANNEL", "general"), |
|
0adbd1e…
|
lmata
|
132 |
Nick: os.Getenv("SCUTTLEBOT_NICK"), // derived below if empty |
|
0adbd1e…
|
lmata
|
133 |
Transport: transport, |
|
0adbd1e…
|
lmata
|
134 |
IRCAddr: envOr("SCUTTLEBOT_IRC_ADDR", "127.0.0.1:6667"), |
|
0adbd1e…
|
lmata
|
135 |
IRCPass: os.Getenv("SCUTTLEBOT_IRC_PASS"), |
|
0adbd1e…
|
lmata
|
136 |
IRCDeleteOnClose: os.Getenv("SCUTTLEBOT_IRC_DELETE_ON_CLOSE") == "1", |
|
0adbd1e…
|
lmata
|
137 |
HooksEnabled: envOr("SCUTTLEBOT_HOOKS_ENABLED", "1") != "0", |
|
0adbd1e…
|
lmata
|
138 |
InterruptOnMessage: os.Getenv("SCUTTLEBOT_INTERRUPT_ON_MESSAGE") == "1", |
|
0adbd1e…
|
lmata
|
139 |
PollInterval: parseDuration("SCUTTLEBOT_POLL_INTERVAL", 2*time.Second), |
|
0adbd1e…
|
lmata
|
140 |
HeartbeatInterval: parseDuration("SCUTTLEBOT_PRESENCE_HEARTBEAT", 60*time.Second), |
|
0adbd1e…
|
lmata
|
141 |
} |
|
0adbd1e…
|
lmata
|
142 |
} |
|
0adbd1e…
|
lmata
|
143 |
``` |
|
0adbd1e…
|
lmata
|
144 |
|
|
0adbd1e…
|
lmata
|
145 |
### 4. Derive the session nick |
|
0adbd1e…
|
lmata
|
146 |
|
|
0adbd1e…
|
lmata
|
147 |
```go |
|
0adbd1e…
|
lmata
|
148 |
func deriveNick(runtime, cwd string) string { |
|
0adbd1e…
|
lmata
|
149 |
// Sanitize the repo directory name. |
|
0adbd1e…
|
lmata
|
150 |
base := sanitize(filepath.Base(cwd)) |
|
0adbd1e…
|
lmata
|
151 |
// Stable 8-char hex from pid + ppid + current time. |
|
0adbd1e…
|
lmata
|
152 |
h := crc32.NewIEEE() |
|
0adbd1e…
|
lmata
|
153 |
fmt.Fprintf(h, "%d%d%d", os.Getpid(), os.Getppid(), time.Now().UnixNano()) |
|
0adbd1e…
|
lmata
|
154 |
suffix := fmt.Sprintf("%08x", h.Sum32()) |
|
0adbd1e…
|
lmata
|
155 |
return fmt.Sprintf("%s-%s-%s", runtime, base, suffix[:8]) |
|
0adbd1e…
|
lmata
|
156 |
} |
|
0adbd1e…
|
lmata
|
157 |
|
|
0adbd1e…
|
lmata
|
158 |
func sanitize(s string) string { |
|
0adbd1e…
|
lmata
|
159 |
re := regexp.MustCompile(`[^a-zA-Z0-9_-]+`) |
|
0adbd1e…
|
lmata
|
160 |
return re.ReplaceAllString(s, "-") |
|
0adbd1e…
|
lmata
|
161 |
} |
|
0adbd1e…
|
lmata
|
162 |
``` |
|
0adbd1e…
|
lmata
|
163 |
|
|
0adbd1e…
|
lmata
|
164 |
Nick format: `{runtime}-{basename}-{session_id[:8]}` |
|
0adbd1e…
|
lmata
|
165 |
|
|
0adbd1e…
|
lmata
|
166 |
For runtimes that expose a stable session UUID (like Claude Code), prefer that over the PID-based suffix. |
|
0adbd1e…
|
lmata
|
167 |
|
|
0adbd1e…
|
lmata
|
168 |
### 5. Implement `run` |
|
0adbd1e…
|
lmata
|
169 |
|
|
0adbd1e…
|
lmata
|
170 |
The top-level `run` function wires everything together: |
|
0adbd1e…
|
lmata
|
171 |
|
|
0adbd1e…
|
lmata
|
172 |
```go |
|
0adbd1e…
|
lmata
|
173 |
func run(ctx context.Context, cfg config) error { |
|
0adbd1e…
|
lmata
|
174 |
conn, err := sessionrelay.New(sessionrelay.Config{ /* ... */ }) |
|
0adbd1e…
|
lmata
|
175 |
if err != nil { |
|
0adbd1e…
|
lmata
|
176 |
return fmt.Errorf("relay: connect: %w", err) |
|
0adbd1e…
|
lmata
|
177 |
} |
|
0adbd1e…
|
lmata
|
178 |
|
|
0adbd1e…
|
lmata
|
179 |
if err := conn.Connect(ctx); err != nil { |
|
0adbd1e…
|
lmata
|
180 |
// Soft-fail: log, then start the runtime anyway. |
|
0adbd1e…
|
lmata
|
181 |
log.Printf("relay: scuttlebot unreachable, running without relay: %v", err) |
|
0adbd1e…
|
lmata
|
182 |
return runRuntimeDirect(ctx, cfg) |
|
0adbd1e…
|
lmata
|
183 |
} |
|
0adbd1e…
|
lmata
|
184 |
defer conn.Close(ctx) |
|
0adbd1e…
|
lmata
|
185 |
|
|
0adbd1e…
|
lmata
|
186 |
// Announce presence. |
|
0adbd1e…
|
lmata
|
187 |
_ = conn.Post(ctx, cfg.Nick+" online") |
|
0adbd1e…
|
lmata
|
188 |
|
|
0adbd1e…
|
lmata
|
189 |
// Start the runtime under a PTY. |
|
0adbd1e…
|
lmata
|
190 |
ptmx, cmd, err := startRuntime(cfg) |
|
0adbd1e…
|
lmata
|
191 |
if err != nil { |
|
0adbd1e…
|
lmata
|
192 |
return fmt.Errorf("relay: start runtime: %w", err) |
|
0adbd1e…
|
lmata
|
193 |
} |
|
0adbd1e…
|
lmata
|
194 |
|
|
0adbd1e…
|
lmata
|
195 |
var wg sync.WaitGroup |
|
0adbd1e…
|
lmata
|
196 |
|
|
0adbd1e…
|
lmata
|
197 |
// Mirror runtime output → IRC. |
|
0adbd1e…
|
lmata
|
198 |
wg.Add(1) |
|
0adbd1e…
|
lmata
|
199 |
go func() { |
|
0adbd1e…
|
lmata
|
200 |
defer wg.Done() |
|
0adbd1e…
|
lmata
|
201 |
mirrorSessionLoop(ctx, cfg, conn, sessionDir(cfg)) |
|
0adbd1e…
|
lmata
|
202 |
}() |
|
0adbd1e…
|
lmata
|
203 |
|
|
0adbd1e…
|
lmata
|
204 |
// Poll IRC → inject into runtime. |
|
0adbd1e…
|
lmata
|
205 |
wg.Add(1) |
|
0adbd1e…
|
lmata
|
206 |
go func() { |
|
0adbd1e…
|
lmata
|
207 |
defer wg.Done() |
|
0adbd1e…
|
lmata
|
208 |
relayInputLoop(ctx, cfg, conn, ptmx) |
|
0adbd1e…
|
lmata
|
209 |
}() |
|
0adbd1e…
|
lmata
|
210 |
|
|
0adbd1e…
|
lmata
|
211 |
// Wait for runtime to exit. |
|
0adbd1e…
|
lmata
|
212 |
_ = cmd.Wait() |
|
0adbd1e…
|
lmata
|
213 |
_ = conn.Post(ctx, cfg.Nick+" offline") |
|
0adbd1e…
|
lmata
|
214 |
wg.Wait() |
|
0adbd1e…
|
lmata
|
215 |
return nil |
|
0adbd1e…
|
lmata
|
216 |
} |
|
0adbd1e…
|
lmata
|
217 |
``` |
|
0adbd1e…
|
lmata
|
218 |
|
|
0adbd1e…
|
lmata
|
219 |
### 6. Implement `mirrorSessionLoop` |
|
0adbd1e…
|
lmata
|
220 |
|
|
0adbd1e…
|
lmata
|
221 |
This goroutine tails the runtime's session JSONL log and posts summarized activity to IRC. |
|
0adbd1e…
|
lmata
|
222 |
|
|
0adbd1e…
|
lmata
|
223 |
```go |
|
0adbd1e…
|
lmata
|
224 |
func mirrorSessionLoop(ctx context.Context, cfg config, conn sessionrelay.Connector, dir string) { |
|
0adbd1e…
|
lmata
|
225 |
ticker := time.NewTicker(250 * time.Millisecond) |
|
0adbd1e…
|
lmata
|
226 |
defer ticker.Stop() |
|
0adbd1e…
|
lmata
|
227 |
|
|
0adbd1e…
|
lmata
|
228 |
var lastPos int64 |
|
0adbd1e…
|
lmata
|
229 |
|
|
0adbd1e…
|
lmata
|
230 |
for { |
|
0adbd1e…
|
lmata
|
231 |
select { |
|
0adbd1e…
|
lmata
|
232 |
case <-ctx.Done(): |
|
0adbd1e…
|
lmata
|
233 |
return |
|
0adbd1e…
|
lmata
|
234 |
case <-ticker.C: |
|
0adbd1e…
|
lmata
|
235 |
file := latestSessionFile(dir) |
|
0adbd1e…
|
lmata
|
236 |
if file == "" { |
|
0adbd1e…
|
lmata
|
237 |
continue |
|
0adbd1e…
|
lmata
|
238 |
} |
|
0adbd1e…
|
lmata
|
239 |
lines, pos := readNewLines(file, lastPos) |
|
0adbd1e…
|
lmata
|
240 |
lastPos = pos |
|
0adbd1e…
|
lmata
|
241 |
for _, line := range lines { |
|
0adbd1e…
|
lmata
|
242 |
if msg := extractActivityLine(line); msg != "" { |
|
0adbd1e…
|
lmata
|
243 |
_ = conn.Post(ctx, msg) |
|
0adbd1e…
|
lmata
|
244 |
} |
|
0adbd1e…
|
lmata
|
245 |
} |
|
0adbd1e…
|
lmata
|
246 |
} |
|
0adbd1e…
|
lmata
|
247 |
} |
|
0adbd1e…
|
lmata
|
248 |
} |
|
0adbd1e…
|
lmata
|
249 |
``` |
|
0adbd1e…
|
lmata
|
250 |
|
|
0adbd1e…
|
lmata
|
251 |
### 7. Implement `relayInputLoop` |
|
0adbd1e…
|
lmata
|
252 |
|
|
0adbd1e…
|
lmata
|
253 |
This goroutine polls the IRC channel for operator messages and injects them into the runtime. |
|
0adbd1e…
|
lmata
|
254 |
|
|
0adbd1e…
|
lmata
|
255 |
```go |
|
0adbd1e…
|
lmata
|
256 |
func relayInputLoop(ctx context.Context, cfg config, conn sessionrelay.Connector, ptmx *os.File) { |
|
0adbd1e…
|
lmata
|
257 |
ticker := time.NewTicker(cfg.PollInterval) |
|
0adbd1e…
|
lmata
|
258 |
defer ticker.Stop() |
|
0adbd1e…
|
lmata
|
259 |
|
|
0adbd1e…
|
lmata
|
260 |
var lastCheck time.Time |
|
0adbd1e…
|
lmata
|
261 |
|
|
0adbd1e…
|
lmata
|
262 |
for { |
|
0adbd1e…
|
lmata
|
263 |
select { |
|
0adbd1e…
|
lmata
|
264 |
case <-ctx.Done(): |
|
0adbd1e…
|
lmata
|
265 |
return |
|
0adbd1e…
|
lmata
|
266 |
case <-ticker.C: |
|
0adbd1e…
|
lmata
|
267 |
msgs, err := conn.MessagesSince(ctx, lastCheck) |
|
0adbd1e…
|
lmata
|
268 |
if err != nil { |
|
0adbd1e…
|
lmata
|
269 |
continue |
|
0adbd1e…
|
lmata
|
270 |
} |
|
0adbd1e…
|
lmata
|
271 |
lastCheck = time.Now() |
|
0adbd1e…
|
lmata
|
272 |
for _, m := range filterInbound(msgs, cfg.Nick) { |
|
0adbd1e…
|
lmata
|
273 |
injectInstruction(ptmx, m.Text) |
|
0adbd1e…
|
lmata
|
274 |
} |
|
0adbd1e…
|
lmata
|
275 |
} |
|
0adbd1e…
|
lmata
|
276 |
} |
|
0adbd1e…
|
lmata
|
277 |
} |
|
0adbd1e…
|
lmata
|
278 |
``` |
|
0adbd1e…
|
lmata
|
279 |
|
|
0adbd1e…
|
lmata
|
280 |
--- |
|
0adbd1e…
|
lmata
|
281 |
|
|
0adbd1e…
|
lmata
|
282 |
## Session file discovery |
|
0adbd1e…
|
lmata
|
283 |
|
|
0adbd1e…
|
lmata
|
284 |
Each runtime stores its session data in a different location: |
|
0adbd1e…
|
lmata
|
285 |
|
|
0adbd1e…
|
lmata
|
286 |
| Runtime | Session log location | |
|
0adbd1e…
|
lmata
|
287 |
|---------|---------------------| |
|
336984b…
|
lmata
|
288 |
| Claude Code | `~/.claude/projects/{cwd-hash}/` — JSONL files named by session UUID | |
|
0adbd1e…
|
lmata
|
289 |
| Codex | `~/.codex/sessions/{session-id}.jsonl` | |
|
0adbd1e…
|
lmata
|
290 |
| Gemini CLI | `~/.gemini/sessions/{session-id}.jsonl` | |
|
0adbd1e…
|
lmata
|
291 |
|
|
0adbd1e…
|
lmata
|
292 |
To find the latest session file: |
|
0adbd1e…
|
lmata
|
293 |
|
|
0adbd1e…
|
lmata
|
294 |
```go |
|
0adbd1e…
|
lmata
|
295 |
func latestSessionFile(dir string) string { |
|
0adbd1e…
|
lmata
|
296 |
entries, _ := os.ReadDir(dir) |
|
0adbd1e…
|
lmata
|
297 |
var newest os.DirEntry |
|
0adbd1e…
|
lmata
|
298 |
for _, e := range entries { |
|
0adbd1e…
|
lmata
|
299 |
if !strings.HasSuffix(e.Name(), ".jsonl") { |
|
0adbd1e…
|
lmata
|
300 |
continue |
|
0adbd1e…
|
lmata
|
301 |
} |
|
0adbd1e…
|
lmata
|
302 |
if newest == nil { |
|
0adbd1e…
|
lmata
|
303 |
newest = e |
|
0adbd1e…
|
lmata
|
304 |
continue |
|
0adbd1e…
|
lmata
|
305 |
} |
|
0adbd1e…
|
lmata
|
306 |
ni, _ := newest.Info() |
|
0adbd1e…
|
lmata
|
307 |
ei, _ := e.Info() |
|
0adbd1e…
|
lmata
|
308 |
if ei.ModTime().After(ni.ModTime()) { |
|
0adbd1e…
|
lmata
|
309 |
newest = e |
|
0adbd1e…
|
lmata
|
310 |
} |
|
0adbd1e…
|
lmata
|
311 |
} |
|
0adbd1e…
|
lmata
|
312 |
if newest == nil { |
|
0adbd1e…
|
lmata
|
313 |
return "" |
|
0adbd1e…
|
lmata
|
314 |
} |
|
0adbd1e…
|
lmata
|
315 |
return filepath.Join(dir, newest.Name()) |
|
0adbd1e…
|
lmata
|
316 |
} |
|
0adbd1e…
|
lmata
|
317 |
``` |
|
0adbd1e…
|
lmata
|
318 |
|
|
0adbd1e…
|
lmata
|
319 |
For Claude Code specifically, the project directory is derived from the working directory path — see `cmd/claude-relay/main.go` for the exact hashing logic. |
|
0adbd1e…
|
lmata
|
320 |
|
|
0adbd1e…
|
lmata
|
321 |
--- |
|
0adbd1e…
|
lmata
|
322 |
|
|
0adbd1e…
|
lmata
|
323 |
## Message parsing — Claude Code JSONL format |
|
0adbd1e…
|
lmata
|
324 |
|
|
0adbd1e…
|
lmata
|
325 |
Each line in a Claude Code session file is a JSON object. The fields you care about: |
|
0adbd1e…
|
lmata
|
326 |
|
|
0adbd1e…
|
lmata
|
327 |
```json |
|
0adbd1e…
|
lmata
|
328 |
{ |
|
0adbd1e…
|
lmata
|
329 |
"type": "assistant", |
|
0adbd1e…
|
lmata
|
330 |
"sessionId": "550e8400-...", |
|
0adbd1e…
|
lmata
|
331 |
"cwd": "/Users/alice/repos/myproject", |
|
0adbd1e…
|
lmata
|
332 |
"message": { |
|
0adbd1e…
|
lmata
|
333 |
"role": "assistant", |
|
0adbd1e…
|
lmata
|
334 |
"content": [ |
|
0adbd1e…
|
lmata
|
335 |
{ |
|
0adbd1e…
|
lmata
|
336 |
"type": "tool_use", |
|
0adbd1e…
|
lmata
|
337 |
"name": "Bash", |
|
0adbd1e…
|
lmata
|
338 |
"input": { "command": "go test ./..." } |
|
0adbd1e…
|
lmata
|
339 |
} |
|
0adbd1e…
|
lmata
|
340 |
] |
|
0adbd1e…
|
lmata
|
341 |
} |
|
0adbd1e…
|
lmata
|
342 |
} |
|
0adbd1e…
|
lmata
|
343 |
``` |
|
0adbd1e…
|
lmata
|
344 |
|
|
0adbd1e…
|
lmata
|
345 |
```json |
|
0adbd1e…
|
lmata
|
346 |
{ |
|
0adbd1e…
|
lmata
|
347 |
"type": "user", |
|
0adbd1e…
|
lmata
|
348 |
"message": { |
|
0adbd1e…
|
lmata
|
349 |
"role": "user", |
|
0adbd1e…
|
lmata
|
350 |
"content": [ |
|
0adbd1e…
|
lmata
|
351 |
{ |
|
0adbd1e…
|
lmata
|
352 |
"type": "tool_result", |
|
0adbd1e…
|
lmata
|
353 |
"content": [{ "type": "text", "text": "ok github.com/..." }] |
|
0adbd1e…
|
lmata
|
354 |
} |
|
0adbd1e…
|
lmata
|
355 |
] |
|
0adbd1e…
|
lmata
|
356 |
} |
|
0adbd1e…
|
lmata
|
357 |
} |
|
0adbd1e…
|
lmata
|
358 |
``` |
|
0adbd1e…
|
lmata
|
359 |
|
|
0adbd1e…
|
lmata
|
360 |
```json |
|
0adbd1e…
|
lmata
|
361 |
{ |
|
0adbd1e…
|
lmata
|
362 |
"type": "result", |
|
0adbd1e…
|
lmata
|
363 |
"subtype": "success" |
|
0adbd1e…
|
lmata
|
364 |
} |
|
0adbd1e…
|
lmata
|
365 |
``` |
|
0adbd1e…
|
lmata
|
366 |
|
|
0adbd1e…
|
lmata
|
367 |
**Extracting activity lines:** |
|
0adbd1e…
|
lmata
|
368 |
|
|
0adbd1e…
|
lmata
|
369 |
```go |
|
0adbd1e…
|
lmata
|
370 |
func extractActivityLine(jsonLine string) string { |
|
0adbd1e…
|
lmata
|
371 |
var entry claudeSessionEntry |
|
0adbd1e…
|
lmata
|
372 |
if err := json.Unmarshal([]byte(jsonLine), &entry); err != nil { |
|
0adbd1e…
|
lmata
|
373 |
return "" |
|
0adbd1e…
|
lmata
|
374 |
} |
|
0adbd1e…
|
lmata
|
375 |
if entry.Type != "assistant" { |
|
0adbd1e…
|
lmata
|
376 |
return "" |
|
0adbd1e…
|
lmata
|
377 |
} |
|
0adbd1e…
|
lmata
|
378 |
for _, block := range entry.Message.Content { |
|
0adbd1e…
|
lmata
|
379 |
switch block.Type { |
|
0adbd1e…
|
lmata
|
380 |
case "tool_use": |
|
0adbd1e…
|
lmata
|
381 |
return summarizeToolUse(block.Name, block.Input) |
|
0adbd1e…
|
lmata
|
382 |
case "text": |
|
0adbd1e…
|
lmata
|
383 |
if block.Text != "" { |
|
0adbd1e…
|
lmata
|
384 |
return truncate(block.Text, 360) |
|
0adbd1e…
|
lmata
|
385 |
} |
|
0adbd1e…
|
lmata
|
386 |
} |
|
0adbd1e…
|
lmata
|
387 |
} |
|
0adbd1e…
|
lmata
|
388 |
return "" |
|
0adbd1e…
|
lmata
|
389 |
} |
|
0adbd1e…
|
lmata
|
390 |
``` |
|
0adbd1e…
|
lmata
|
391 |
|
|
0adbd1e…
|
lmata
|
392 |
For other runtimes, identify the equivalent fields in their session format. Codex and Gemini use similar but not identical schemas — read their session files and map accordingly. |
|
0adbd1e…
|
lmata
|
393 |
|
|
0adbd1e…
|
lmata
|
394 |
**Secret scrubbing:** Before posting any line to IRC, run it through a scrubber: |
|
0adbd1e…
|
lmata
|
395 |
|
|
0adbd1e…
|
lmata
|
396 |
```go |
|
0adbd1e…
|
lmata
|
397 |
var ( |
|
0adbd1e…
|
lmata
|
398 |
secretHexPattern = regexp.MustCompile(`\b[a-f0-9]{32,}\b`) |
|
0adbd1e…
|
lmata
|
399 |
secretKeyPattern = regexp.MustCompile(`\bsk-[A-Za-z0-9_-]+\b`) |
|
0adbd1e…
|
lmata
|
400 |
bearerPattern = regexp.MustCompile(`(?i)(bearer\s+)([A-Za-z0-9._:-]+)`) |
|
0adbd1e…
|
lmata
|
401 |
assignTokenPattern = regexp.MustCompile(`(?i)\b([A-Z0-9_]*(TOKEN|KEY|SECRET|PASSPHRASE)[A-Z0-9_]*=)([^ \t"'\x60]+)`) |
|
0adbd1e…
|
lmata
|
402 |
) |
|
0adbd1e…
|
lmata
|
403 |
|
|
0adbd1e…
|
lmata
|
404 |
func scrubSecrets(s string) string { |
|
0adbd1e…
|
lmata
|
405 |
s = secretHexPattern.ReplaceAllString(s, "[redacted]") |
|
0adbd1e…
|
lmata
|
406 |
s = secretKeyPattern.ReplaceAllString(s, "[redacted]") |
|
0adbd1e…
|
lmata
|
407 |
s = bearerPattern.ReplaceAllStringFunc(s, func(m string) string { |
|
0adbd1e…
|
lmata
|
408 |
parts := bearerPattern.FindStringSubmatch(m) |
|
0adbd1e…
|
lmata
|
409 |
return parts[1] + "[redacted]" |
|
0adbd1e…
|
lmata
|
410 |
}) |
|
0adbd1e…
|
lmata
|
411 |
s = assignTokenPattern.ReplaceAllString(s, "${1}[redacted]") |
|
0adbd1e…
|
lmata
|
412 |
return s |
|
0adbd1e…
|
lmata
|
413 |
} |
|
0adbd1e…
|
lmata
|
414 |
``` |
|
0adbd1e…
|
lmata
|
415 |
|
|
0adbd1e…
|
lmata
|
416 |
--- |
|
0adbd1e…
|
lmata
|
417 |
|
|
0adbd1e…
|
lmata
|
418 |
## Filtering rules for inbound messages |
|
0adbd1e…
|
lmata
|
419 |
|
|
0adbd1e…
|
lmata
|
420 |
Not every message in the channel is meant for this session. The filter must accept only messages that are **all** of the following: |
|
0adbd1e…
|
lmata
|
421 |
|
|
0adbd1e…
|
lmata
|
422 |
1. **Newer than the last check** — track a `lastCheck time.Time` per session key (see below) |
|
0adbd1e…
|
lmata
|
423 |
2. **Not from this session's own nick** — reject self-messages |
|
0adbd1e…
|
lmata
|
424 |
3. **Not from a known service bot** — reject: `bridge`, `oracle`, `sentinel`, `steward`, `scribe`, `warden`, `snitch`, `herald`, `scroll`, `systembot`, `auditbot` |
|
0adbd1e…
|
lmata
|
425 |
4. **Not from an agent status nick** — reject nicks with prefixes `claude-`, `codex-`, `gemini-` |
|
0adbd1e…
|
lmata
|
426 |
5. **Explicitly mentioning this session nick** — the message text must contain the nick as a word boundary match, not just as a substring |
|
0adbd1e…
|
lmata
|
427 |
|
|
0adbd1e…
|
lmata
|
428 |
```go |
|
0adbd1e…
|
lmata
|
429 |
var serviceBots = map[string]struct{}{ |
|
0adbd1e…
|
lmata
|
430 |
"bridge": {}, "oracle": {}, "sentinel": {}, "steward": {}, |
|
0adbd1e…
|
lmata
|
431 |
"scribe": {}, "warden": {}, "snitch": {}, "herald": {}, |
|
0adbd1e…
|
lmata
|
432 |
"scroll": {}, "systembot": {}, "auditbot": {}, |
|
0adbd1e…
|
lmata
|
433 |
} |
|
0adbd1e…
|
lmata
|
434 |
|
|
0adbd1e…
|
lmata
|
435 |
var agentPrefixes = []string{"claude-", "codex-", "gemini-"} |
|
0adbd1e…
|
lmata
|
436 |
|
|
0adbd1e…
|
lmata
|
437 |
func filterInbound(msgs []sessionrelay.Message, selfNick string) []sessionrelay.Message { |
|
0adbd1e…
|
lmata
|
438 |
var out []sessionrelay.Message |
|
0adbd1e…
|
lmata
|
439 |
mentionRe := regexp.MustCompile( |
|
0adbd1e…
|
lmata
|
440 |
`(^|[^[:alnum:]_./\\-])` + regexp.QuoteMeta(selfNick) + `($|[^[:alnum:]_./\\-])`, |
|
0adbd1e…
|
lmata
|
441 |
) |
|
0adbd1e…
|
lmata
|
442 |
for _, m := range msgs { |
|
0adbd1e…
|
lmata
|
443 |
if m.Nick == selfNick { |
|
0adbd1e…
|
lmata
|
444 |
continue |
|
0adbd1e…
|
lmata
|
445 |
} |
|
0adbd1e…
|
lmata
|
446 |
if _, ok := serviceBots[m.Nick]; ok { |
|
0adbd1e…
|
lmata
|
447 |
continue |
|
0adbd1e…
|
lmata
|
448 |
} |
|
0adbd1e…
|
lmata
|
449 |
isAgentNick := false |
|
0adbd1e…
|
lmata
|
450 |
for _, p := range agentPrefixes { |
|
0adbd1e…
|
lmata
|
451 |
if strings.HasPrefix(m.Nick, p) { |
|
0adbd1e…
|
lmata
|
452 |
isAgentNick = true |
|
0adbd1e…
|
lmata
|
453 |
break |
|
0adbd1e…
|
lmata
|
454 |
} |
|
0adbd1e…
|
lmata
|
455 |
} |
|
0adbd1e…
|
lmata
|
456 |
if isAgentNick { |
|
0adbd1e…
|
lmata
|
457 |
continue |
|
0adbd1e…
|
lmata
|
458 |
} |
|
0adbd1e…
|
lmata
|
459 |
if !mentionRe.MatchString(m.Text) { |
|
0adbd1e…
|
lmata
|
460 |
continue |
|
0adbd1e…
|
lmata
|
461 |
} |
|
0adbd1e…
|
lmata
|
462 |
out = append(out, m) |
|
0adbd1e…
|
lmata
|
463 |
} |
|
0adbd1e…
|
lmata
|
464 |
return out |
|
0adbd1e…
|
lmata
|
465 |
} |
|
0adbd1e…
|
lmata
|
466 |
``` |
|
0adbd1e…
|
lmata
|
467 |
|
|
0adbd1e…
|
lmata
|
468 |
**Why these rules matter:** |
|
0adbd1e…
|
lmata
|
469 |
|
|
0adbd1e…
|
lmata
|
470 |
- Service bots post frequently (scribe, systembot, auditbot log every event). Letting those through would create feedback loops. |
|
0adbd1e…
|
lmata
|
471 |
- Agent nicks with runtime prefixes are other sessions' activity mirrors. They are ambient background, not operator instructions. |
|
0adbd1e…
|
lmata
|
472 |
- Word-boundary mention matching prevents `claude-myrepo-abc12345` from triggering on a message that just contains the word `claude`. |
|
0adbd1e…
|
lmata
|
473 |
|
|
0adbd1e…
|
lmata
|
474 |
**State scoping:** Do not use a single global timestamp file. Track `lastCheck` by a key derived from `channel + nick + cwd`. This prevents parallel sessions in the same channel from consuming each other's instructions: |
|
0adbd1e…
|
lmata
|
475 |
|
|
0adbd1e…
|
lmata
|
476 |
```go |
|
0adbd1e…
|
lmata
|
477 |
func stateKey(channel, nick, cwd string) string { |
|
0adbd1e…
|
lmata
|
478 |
h := fmt.Sprintf("%s|%s|%s", channel, nick, cwd) |
|
0adbd1e…
|
lmata
|
479 |
sum := crc32.ChecksumIEEE([]byte(h)) |
|
0adbd1e…
|
lmata
|
480 |
return fmt.Sprintf("%08x", sum) |
|
0adbd1e…
|
lmata
|
481 |
} |
|
0adbd1e…
|
lmata
|
482 |
``` |
|
0adbd1e…
|
lmata
|
483 |
|
|
0adbd1e…
|
lmata
|
484 |
--- |
|
0adbd1e…
|
lmata
|
485 |
|
|
0adbd1e…
|
lmata
|
486 |
## The environment contract |
|
0adbd1e…
|
lmata
|
487 |
|
|
0adbd1e…
|
lmata
|
488 |
All relay brokers use the same set of environment variables. Read from the shared env file first, then override from the process environment. |
|
0adbd1e…
|
lmata
|
489 |
|
|
0adbd1e…
|
lmata
|
490 |
**Required:** |
|
0adbd1e…
|
lmata
|
491 |
|
|
0adbd1e…
|
lmata
|
492 |
| Variable | Purpose | |
|
0adbd1e…
|
lmata
|
493 |
|----------|---------| |
|
0adbd1e…
|
lmata
|
494 |
| `SCUTTLEBOT_URL` | Base URL of the scuttlebot HTTP API (e.g. `https://scuttlebot.example.com`) | |
|
0adbd1e…
|
lmata
|
495 |
| `SCUTTLEBOT_TOKEN` | Bearer token for API auth | |
|
0adbd1e…
|
lmata
|
496 |
| `SCUTTLEBOT_CHANNEL` | Target IRC channel (with or without `#`) | |
|
0adbd1e…
|
lmata
|
497 |
|
|
0adbd1e…
|
lmata
|
498 |
**Common optional:** |
|
0adbd1e…
|
lmata
|
499 |
|
|
0adbd1e…
|
lmata
|
500 |
| Variable | Default | Purpose | |
|
0adbd1e…
|
lmata
|
501 |
|----------|---------|---------| |
|
0adbd1e…
|
lmata
|
502 |
| `SCUTTLEBOT_TRANSPORT` | `irc` | `http` (bridge path) or `irc` (direct SASL) | |
|
0adbd1e…
|
lmata
|
503 |
| `SCUTTLEBOT_NICK` | derived | Override the session nick | |
|
0adbd1e…
|
lmata
|
504 |
| `SCUTTLEBOT_SESSION_ID` | derived | Stable session ID for nick derivation | |
|
0adbd1e…
|
lmata
|
505 |
| `SCUTTLEBOT_IRC_ADDR` | `127.0.0.1:6667` | Ergo IRC address | |
|
0adbd1e…
|
lmata
|
506 |
| `SCUTTLEBOT_IRC_PASS` | — | IRC password (if different from API token) | |
|
0adbd1e…
|
lmata
|
507 |
| `SCUTTLEBOT_IRC_DELETE_ON_CLOSE` | `0` | Delete the IRC account when the session ends | |
|
0adbd1e…
|
lmata
|
508 |
| `SCUTTLEBOT_HOOKS_ENABLED` | `1` | Set to `0` to disable all IRC integration | |
|
0adbd1e…
|
lmata
|
509 |
| `SCUTTLEBOT_INTERRUPT_ON_MESSAGE` | `0` | Send SIGINT to runtime when operator message arrives | |
|
0adbd1e…
|
lmata
|
510 |
| `SCUTTLEBOT_POLL_INTERVAL` | `2s` | How often to poll for new IRC messages | |
|
0adbd1e…
|
lmata
|
511 |
| `SCUTTLEBOT_PRESENCE_HEARTBEAT` | `60s` | HTTP presence touch interval; `0` to disable | |
|
0adbd1e…
|
lmata
|
512 |
| `SCUTTLEBOT_CONFIG_FILE` | `~/.config/scuttlebot-relay.env` | Path to the shared env file | |
|
0adbd1e…
|
lmata
|
513 |
| `SCUTTLEBOT_ACTIVITY_VIA_BROKER` | `0` | Set to `1` when the broker owns activity posts (disables hook-based posting) | |
|
0adbd1e…
|
lmata
|
514 |
|
|
0adbd1e…
|
lmata
|
515 |
**Do not hardcode tokens.** The shared env file (`~/.config/scuttlebot-relay.env`) is the right place for `SCUTTLEBOT_TOKEN`. Never commit it. |
|
0adbd1e…
|
lmata
|
516 |
|
|
0adbd1e…
|
lmata
|
517 |
--- |
|
0adbd1e…
|
lmata
|
518 |
|
|
0adbd1e…
|
lmata
|
519 |
## Writing the installer script |
|
0adbd1e…
|
lmata
|
520 |
|
|
0adbd1e…
|
lmata
|
521 |
The installer script lives at `skills/{runtime}-relay/scripts/install-{runtime}-relay.sh`. It: |
|
0adbd1e…
|
lmata
|
522 |
|
|
0adbd1e…
|
lmata
|
523 |
1. Writes the shared env file (`~/.config/scuttlebot-relay.env`) |
|
0adbd1e…
|
lmata
|
524 |
2. Copies hook scripts to the runtime's hook directory |
|
0adbd1e…
|
lmata
|
525 |
3. Registers hooks in the runtime's settings JSON |
|
0adbd1e…
|
lmata
|
526 |
4. Copies (or builds) the relay launcher to `~/.local/bin/{runtime}-relay` |
|
0adbd1e…
|
lmata
|
527 |
|
|
0adbd1e…
|
lmata
|
528 |
Key conventions: |
|
0adbd1e…
|
lmata
|
529 |
|
|
0adbd1e…
|
lmata
|
530 |
- Accept `--url`, `--token`, `--channel` flags |
|
0adbd1e…
|
lmata
|
531 |
- Fall back to `SCUTTLEBOT_URL`, `SCUTTLEBOT_TOKEN`, `SCUTTLEBOT_CHANNEL` env vars |
|
0adbd1e…
|
lmata
|
532 |
- Default config file to `~/.config/scuttlebot-relay.env` |
|
0adbd1e…
|
lmata
|
533 |
- Default hooks dir to `~/.{runtime}/hooks/` |
|
0adbd1e…
|
lmata
|
534 |
- Default bin dir to `~/.local/bin/` |
|
0adbd1e…
|
lmata
|
535 |
- Print a clear summary of what was written |
|
0adbd1e…
|
lmata
|
536 |
|
|
0adbd1e…
|
lmata
|
537 |
```bash |
|
0adbd1e…
|
lmata
|
538 |
#!/usr/bin/env bash |
|
0adbd1e…
|
lmata
|
539 |
set -euo pipefail |
|
0adbd1e…
|
lmata
|
540 |
|
|
0adbd1e…
|
lmata
|
541 |
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) |
|
0adbd1e…
|
lmata
|
542 |
REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd) |
|
0adbd1e…
|
lmata
|
543 |
|
|
0adbd1e…
|
lmata
|
544 |
SCUTTLEBOT_URL_VALUE="${SCUTTLEBOT_URL:-}" |
|
0adbd1e…
|
lmata
|
545 |
SCUTTLEBOT_TOKEN_VALUE="${SCUTTLEBOT_TOKEN:-}" |
|
0adbd1e…
|
lmata
|
546 |
SCUTTLEBOT_CHANNEL_VALUE="${SCUTTLEBOT_CHANNEL:-}" |
|
0adbd1e…
|
lmata
|
547 |
|
|
0adbd1e…
|
lmata
|
548 |
CONFIG_FILE="${SCUTTLEBOT_CONFIG_FILE:-$HOME/.config/scuttlebot-relay.env}" |
|
0adbd1e…
|
lmata
|
549 |
HOOKS_DIR="${RUNTIME_HOOKS_DIR:-$HOME/.{runtime}/hooks}" |
|
0adbd1e…
|
lmata
|
550 |
BIN_DIR="${BIN_DIR:-$HOME/.local/bin}" |
|
0adbd1e…
|
lmata
|
551 |
|
|
0adbd1e…
|
lmata
|
552 |
# ... flag parsing ... |
|
0adbd1e…
|
lmata
|
553 |
|
|
0adbd1e…
|
lmata
|
554 |
mkdir -p "$(dirname "$CONFIG_FILE")" "$HOOKS_DIR" "$BIN_DIR" |
|
0adbd1e…
|
lmata
|
555 |
|
|
0adbd1e…
|
lmata
|
556 |
cat > "$CONFIG_FILE" <<EOF |
|
0adbd1e…
|
lmata
|
557 |
SCUTTLEBOT_URL=${SCUTTLEBOT_URL_VALUE} |
|
0adbd1e…
|
lmata
|
558 |
SCUTTLEBOT_TOKEN=${SCUTTLEBOT_TOKEN_VALUE} |
|
0adbd1e…
|
lmata
|
559 |
SCUTTLEBOT_CHANNEL=${SCUTTLEBOT_CHANNEL_VALUE} |
|
0adbd1e…
|
lmata
|
560 |
SCUTTLEBOT_HOOKS_ENABLED=1 |
|
0adbd1e…
|
lmata
|
561 |
EOF |
|
0adbd1e…
|
lmata
|
562 |
|
|
0adbd1e…
|
lmata
|
563 |
cp "$REPO_ROOT/skills/{runtime}-relay/hooks/scuttlebot-check.sh" "$HOOKS_DIR/" |
|
0adbd1e…
|
lmata
|
564 |
cp "$REPO_ROOT/skills/{runtime}-relay/hooks/scuttlebot-post.sh" "$HOOKS_DIR/" |
|
0adbd1e…
|
lmata
|
565 |
chmod +x "$HOOKS_DIR"/scuttlebot-*.sh |
|
0adbd1e…
|
lmata
|
566 |
|
|
0adbd1e…
|
lmata
|
567 |
# Register hooks in runtime settings (runtime-specific). |
|
0adbd1e…
|
lmata
|
568 |
# ... |
|
0adbd1e…
|
lmata
|
569 |
|
|
0adbd1e…
|
lmata
|
570 |
cp "$REPO_ROOT/bin/{runtime}-relay" "$BIN_DIR/{runtime}-relay" |
|
0adbd1e…
|
lmata
|
571 |
chmod +x "$BIN_DIR/{runtime}-relay" |
|
0adbd1e…
|
lmata
|
572 |
|
|
0adbd1e…
|
lmata
|
573 |
echo "Installed. Launch with: $BIN_DIR/{runtime}-relay" |
|
0adbd1e…
|
lmata
|
574 |
``` |
|
0adbd1e…
|
lmata
|
575 |
|
|
0adbd1e…
|
lmata
|
576 |
--- |
|
0adbd1e…
|
lmata
|
577 |
|
|
0adbd1e…
|
lmata
|
578 |
## Writing the hook scripts |
|
0adbd1e…
|
lmata
|
579 |
|
|
0adbd1e…
|
lmata
|
580 |
Hooks fire at runtime lifecycle points. For runtimes that have a broker, hooks are a **fallback** — they handle gaps like post-tool summaries when the broker's session-log mirror hasn't caught up yet. |
|
0adbd1e…
|
lmata
|
581 |
|
|
0adbd1e…
|
lmata
|
582 |
### Pre-action hook (`scuttlebot-check.sh`) |
|
0adbd1e…
|
lmata
|
583 |
|
|
0adbd1e…
|
lmata
|
584 |
Runs before each tool call. Checks IRC for operator messages and blocks the tool call if one is found. |
|
0adbd1e…
|
lmata
|
585 |
|
|
0adbd1e…
|
lmata
|
586 |
Key points: |
|
0adbd1e…
|
lmata
|
587 |
|
|
0adbd1e…
|
lmata
|
588 |
- Load the shared env file first |
|
0adbd1e…
|
lmata
|
589 |
- Derive the nick from session ID and CWD (same logic as the broker) |
|
0adbd1e…
|
lmata
|
590 |
- Compute the state key from channel + nick + CWD, read/write `lastCheck` from `/tmp/` |
|
0adbd1e…
|
lmata
|
591 |
- Fetch `GET /v1/channels/{ch}/messages` with `connect-timeout 1 max-time 2` (never block the tool loop) |
|
0adbd1e…
|
lmata
|
592 |
- Filter messages with the same rules as the broker |
|
0adbd1e…
|
lmata
|
593 |
- If an instruction exists, output `{"decision": "block", "reason": "[IRC] nick: text"}` and exit 0 |
|
0adbd1e…
|
lmata
|
594 |
- If not, exit 0 with no output (tool proceeds normally) |
|
0adbd1e…
|
lmata
|
595 |
|
|
0adbd1e…
|
lmata
|
596 |
```bash |
|
0adbd1e…
|
lmata
|
597 |
messages=$(curl -sf --connect-timeout 1 --max-time 2 \ |
|
0adbd1e…
|
lmata
|
598 |
-H "Authorization: Bearer $SCUTTLEBOT_TOKEN" \ |
|
0adbd1e…
|
lmata
|
599 |
"$SCUTTLEBOT_URL/v1/channels/$SCUTTLEBOT_CHANNEL/messages" 2>/dev/null) |
|
0adbd1e…
|
lmata
|
600 |
|
|
0adbd1e…
|
lmata
|
601 |
[ -z "$messages" ] && exit 0 |
|
0adbd1e…
|
lmata
|
602 |
|
|
0adbd1e…
|
lmata
|
603 |
BOTS='["bridge","oracle","sentinel","steward","scribe","warden","snitch","herald","scroll","systembot","auditbot"]' |
|
0adbd1e…
|
lmata
|
604 |
|
|
0adbd1e…
|
lmata
|
605 |
instruction=$(echo "$messages" | jq -r \ |
|
0adbd1e…
|
lmata
|
606 |
--argjson bots "$BOTS" --arg self "$SCUTTLEBOT_NICK" ' |
|
0adbd1e…
|
lmata
|
607 |
.messages[] |
|
0adbd1e…
|
lmata
|
608 |
| select(.nick as $n | |
|
0adbd1e…
|
lmata
|
609 |
($bots | index($n) | not) and |
|
0adbd1e…
|
lmata
|
610 |
($n | startswith("claude-") | not) and |
|
0adbd1e…
|
lmata
|
611 |
($n | startswith("codex-") | not) and |
|
0adbd1e…
|
lmata
|
612 |
($n | startswith("gemini-") | not) and |
|
0adbd1e…
|
lmata
|
613 |
$n != $self) |
|
0adbd1e…
|
lmata
|
614 |
| "\(.at)\t\(.nick)\t\(.text)" |
|
0adbd1e…
|
lmata
|
615 |
' 2>/dev/null | while IFS=$'\t' read -r at nick text; do |
|
0adbd1e…
|
lmata
|
616 |
# ... timestamp comparison, mention check ... |
|
0adbd1e…
|
lmata
|
617 |
echo "$nick: $text" |
|
0adbd1e…
|
lmata
|
618 |
done | tail -1) |
|
0adbd1e…
|
lmata
|
619 |
|
|
0adbd1e…
|
lmata
|
620 |
[ -z "$instruction" ] && exit 0 |
|
0adbd1e…
|
lmata
|
621 |
echo "{\"decision\": \"block\", \"reason\": \"[IRC instruction from operator] $instruction\"}" |
|
0adbd1e…
|
lmata
|
622 |
``` |
|
0adbd1e…
|
lmata
|
623 |
|
|
0adbd1e…
|
lmata
|
624 |
### Post-action hook (`scuttlebot-post.sh`) |
|
0adbd1e…
|
lmata
|
625 |
|
|
0adbd1e…
|
lmata
|
626 |
Runs after each tool call. Posts a one-line summary to IRC. |
|
0adbd1e…
|
lmata
|
627 |
|
|
0adbd1e…
|
lmata
|
628 |
Key points: |
|
0adbd1e…
|
lmata
|
629 |
|
|
0adbd1e…
|
lmata
|
630 |
- Skip if `SCUTTLEBOT_ACTIVITY_VIA_BROKER=1` — the broker already owns activity posting |
|
0adbd1e…
|
lmata
|
631 |
- Skip if `SCUTTLEBOT_HOOKS_ENABLED=0` or token is empty |
|
0adbd1e…
|
lmata
|
632 |
- Parse the tool name and key input from stdin JSON |
|
0adbd1e…
|
lmata
|
633 |
- Build a short human-readable summary (under 120 chars) |
|
0adbd1e…
|
lmata
|
634 |
- `POST /v1/channels/{ch}/messages` with `connect-timeout 1 max-time 2` |
|
0adbd1e…
|
lmata
|
635 |
- Exit 0 always (never block the tool) |
|
0adbd1e…
|
lmata
|
636 |
|
|
0adbd1e…
|
lmata
|
637 |
Example summaries by tool: |
|
0adbd1e…
|
lmata
|
638 |
|
|
0adbd1e…
|
lmata
|
639 |
| Tool | Summary format | |
|
0adbd1e…
|
lmata
|
640 |
|------|---------------| |
|
0adbd1e…
|
lmata
|
641 |
| `Bash` | `› {command[:120]}` | |
|
0adbd1e…
|
lmata
|
642 |
| `Read` | `read {relative-path}` | |
|
0adbd1e…
|
lmata
|
643 |
| `Edit` | `edit {relative-path}` | |
|
0adbd1e…
|
lmata
|
644 |
| `Write` | `write {relative-path}` | |
|
0adbd1e…
|
lmata
|
645 |
| `Glob` | `glob {pattern}` | |
|
0adbd1e…
|
lmata
|
646 |
| `Grep` | `grep "{pattern}"` | |
|
0adbd1e…
|
lmata
|
647 |
| `Agent` | `spawn agent: {description[:80]}` | |
|
0adbd1e…
|
lmata
|
648 |
| Other | `{tool_name}` | |
|
0adbd1e…
|
lmata
|
649 |
|
|
0adbd1e…
|
lmata
|
650 |
--- |
|
0adbd1e…
|
lmata
|
651 |
|
|
0adbd1e…
|
lmata
|
652 |
## The smoke test checklist |
|
0adbd1e…
|
lmata
|
653 |
|
|
0adbd1e…
|
lmata
|
654 |
Every adapter must pass this test before it is considered complete: |
|
0adbd1e…
|
lmata
|
655 |
|
|
0adbd1e…
|
lmata
|
656 |
1. **Online presence** — launch the runtime or broker; confirm `{nick} online` appears in the IRC channel within a few seconds |
|
0adbd1e…
|
lmata
|
657 |
2. **Tool activity mirror** — trigger one harmless tool call (e.g. list files); confirm a mirrored one-liner appears in the channel |
|
0adbd1e…
|
lmata
|
658 |
3. **Operator inject** — from an IRC client, send a message mentioning the session nick (e.g. `claude-myrepo-abc12345: please stop`); confirm the runtime surfaces it as a blocking instruction or injects it into stdin |
|
0adbd1e…
|
lmata
|
659 |
4. **Offline presence** — exit the runtime; confirm `{nick} offline` appears in the channel |
|
0adbd1e…
|
lmata
|
660 |
5. **Soft-fail** — stop scuttlebot and launch the runtime; confirm it starts normally and the relay exits gracefully |
|
0adbd1e…
|
lmata
|
661 |
|
|
0adbd1e…
|
lmata
|
662 |
If any of these fail, the adapter is not finished. |
|
0adbd1e…
|
lmata
|
663 |
|
|
0adbd1e…
|
lmata
|
664 |
--- |
|
0adbd1e…
|
lmata
|
665 |
|
|
0adbd1e…
|
lmata
|
666 |
## Common mistakes |
|
0adbd1e…
|
lmata
|
667 |
|
|
0adbd1e…
|
lmata
|
668 |
### Duplicate activity posts |
|
0adbd1e…
|
lmata
|
669 |
|
|
0adbd1e…
|
lmata
|
670 |
If the broker mirrors the session log AND the post-hook fires for the same tool call, operators see every action twice. |
|
0adbd1e…
|
lmata
|
671 |
|
|
0adbd1e…
|
lmata
|
672 |
**Fix:** Set `SCUTTLEBOT_ACTIVITY_VIA_BROKER=1` in the env file when the broker is active. The post-hook checks this variable and exits early: |
|
0adbd1e…
|
lmata
|
673 |
|
|
0adbd1e…
|
lmata
|
674 |
```bash |
|
0adbd1e…
|
lmata
|
675 |
[ "${SCUTTLEBOT_ACTIVITY_VIA_BROKER:-0}" = "1" ] && exit 0 |
|
0adbd1e…
|
lmata
|
676 |
``` |
|
0adbd1e…
|
lmata
|
677 |
|
|
0adbd1e…
|
lmata
|
678 |
### Parallel session interference |
|
0adbd1e…
|
lmata
|
679 |
|
|
0adbd1e…
|
lmata
|
680 |
If two sessions in the same repo and channel use a single shared `lastCheck` timestamp file, one session will consume instructions meant for the other. |
|
0adbd1e…
|
lmata
|
681 |
|
|
0adbd1e…
|
lmata
|
682 |
**Fix:** Key the state file by `channel + nick + cwd` (see "State scoping" above). Each session gets its own file under `/tmp/`. |
|
0adbd1e…
|
lmata
|
683 |
|
|
0adbd1e…
|
lmata
|
684 |
### Secrets in activity output |
|
0adbd1e…
|
lmata
|
685 |
|
|
0adbd1e…
|
lmata
|
686 |
Session logs may contain tokens, passphrases, or API keys in command output or assistant text. Posting these to IRC leaks them to everyone in the channel. |
|
0adbd1e…
|
lmata
|
687 |
|
|
0adbd1e…
|
lmata
|
688 |
**Fix:** Always run the scrubber on any line before posting. Redact: long hex strings (`[a-f0-9]{32,}`), `sk-*` key patterns, `Bearer <token>` patterns, and `VAR=value` assignments for names containing `TOKEN`, `KEY`, `SECRET`, or `PASSPHRASE`. |
|
0adbd1e…
|
lmata
|
689 |
|
|
0adbd1e…
|
lmata
|
690 |
### Missing word-boundary check for mentions |
|
0adbd1e…
|
lmata
|
691 |
|
|
0adbd1e…
|
lmata
|
692 |
A check like `echo "$text" | grep -q "$nick"` will match `claude-myrepo-abc12345` inside `re-claude-myrepo-abc12345d` or as part of a URL. Use the word-boundary regex from the filtering rules section. |
|
0adbd1e…
|
lmata
|
693 |
|
|
0adbd1e…
|
lmata
|
694 |
### Blocking the tool loop |
|
0adbd1e…
|
lmata
|
695 |
|
|
0adbd1e…
|
lmata
|
696 |
The pre-action hook runs synchronously before every tool call. If it hangs (e.g. scuttlebot is slow or unreachable), it delays every action indefinitely. |
|
0adbd1e…
|
lmata
|
697 |
|
|
0adbd1e…
|
lmata
|
698 |
**Fix:** Always use `--connect-timeout 1 --max-time 2` in curl calls. Exit 0 immediately on any curl error. The relay is a best-effort observer — it must never impede the runtime. |