ScuttleBot

feat: SCUTTLEBOT_MIRROR_REASONING flag for claude-relay and codex-relay Opt-in env var (default off) to include thinking/reasoning blocks in IRC output, prefixed with "💭 ". Off by default — too verbose for normal use, but useful for debugging and observability. - claude-relay: surfaces "thinking" content blocks from Claude Code JSONL - codex-relay: surfaces "reasoning" content type from Codex session output - gemini-relay: no structured reasoning blocks in PTY stream — unchanged

lmata 2026-04-02 23:09 trunk
Commit 67e01782b8c25441b9f2c4561c23b8075933a2e98487b07b5d9dedce8579c89c
--- cmd/claude-relay/main.go
+++ cmd/claude-relay/main.go
@@ -77,10 +77,11 @@
7777
ChannelStateFile string
7878
SessionID string
7979
Nick string
8080
HooksEnabled bool
8181
InterruptOnMessage bool
82
+ MirrorReasoning bool
8283
PollInterval time.Duration
8384
HeartbeatInterval time.Duration
8485
TargetCWD string
8586
Args []string
8687
}
@@ -131,10 +132,11 @@
131132
_ = sessionrelay.RemoveChannelStateFile(cfg.ChannelStateFile)
132133
defer func() { _ = sessionrelay.RemoveChannelStateFile(cfg.ChannelStateFile) }()
133134
134135
var relay sessionrelay.Connector
135136
relayActive := false
137
+ var onlineAt time.Time
136138
if relayRequested {
137139
conn, err := sessionrelay.New(sessionrelay.Config{
138140
Transport: cfg.Transport,
139141
URL: cfg.URL,
140142
Token: cfg.Token,
@@ -159,10 +161,11 @@
159161
relay = conn
160162
relayActive = true
161163
if err := sessionrelay.WriteChannelStateFile(cfg.ChannelStateFile, relay.ControlChannel(), relay.Channels()); err != nil {
162164
fmt.Fprintf(os.Stderr, "claude-relay: channel state disabled: %v\n", err)
163165
}
166
+ onlineAt = time.Now()
164167
_ = relay.Post(context.Background(), fmt.Sprintf(
165168
"online in %s; mention %s to interrupt before the next action",
166169
filepath.Base(cfg.TargetCWD), cfg.Nick,
167170
))
168171
}
@@ -245,11 +248,11 @@
245248
}()
246249
go func() {
247250
copyPTYOutput(ptmx, os.Stdout, state)
248251
}()
249252
if relayActive {
250
- go relayInputLoop(ctx, relay, cfg, state, ptmx)
253
+ go relayInputLoop(ctx, relay, cfg, state, ptmx, onlineAt)
251254
}
252255
253256
err = cmd.Wait()
254257
cancel()
255258
@@ -268,11 +271,11 @@
268271
if ctx.Err() == nil {
269272
_ = relay.Post(context.Background(), fmt.Sprintf("mirror failed: %v — session activity not visible in IRC", err))
270273
}
271274
return
272275
}
273
- if err := tailSessionFile(ctx, sessionPath, func(text string) {
276
+ if err := tailSessionFile(ctx, sessionPath, cfg.MirrorReasoning, func(text string) {
274277
for _, line := range splitMirrorText(text) {
275278
if line == "" {
276279
continue
277280
}
278281
_ = relay.Post(ctx, line)
@@ -385,11 +388,11 @@
385388
return entry.CWD == targetCWD
386389
}
387390
return false
388391
}
389392
390
-func tailSessionFile(ctx context.Context, path string, emit func(string)) error {
393
+func tailSessionFile(ctx context.Context, path string, mirrorReasoning bool, emit func(string)) error {
391394
file, err := os.Open(path)
392395
if err != nil {
393396
return err
394397
}
395398
defer file.Close()
@@ -400,11 +403,11 @@
400403
401404
reader := bufio.NewReader(file)
402405
for {
403406
line, err := reader.ReadBytes('\n')
404407
if len(line) > 0 {
405
- for _, text := range sessionMessages(line) {
408
+ for _, text := range sessionMessages(line, mirrorReasoning) {
406409
if text != "" {
407410
emit(text)
408411
}
409412
}
410413
}
@@ -422,11 +425,12 @@
422425
return err
423426
}
424427
}
425428
426429
// sessionMessages parses a Claude Code JSONL line and returns IRC-ready strings.
427
-func sessionMessages(line []byte) []string {
430
+// If mirrorReasoning is true, thinking blocks are included prefixed with "💭 ".
431
+func sessionMessages(line []byte, mirrorReasoning bool) []string {
428432
var entry claudeSessionEntry
429433
if err := json.Unmarshal(line, &entry); err != nil {
430434
return nil
431435
}
432436
if entry.Type != "assistant" || entry.Message.Role != "assistant" {
@@ -444,11 +448,18 @@
444448
}
445449
case "tool_use":
446450
if msg := summarizeToolUse(block.Name, block.Input); msg != "" {
447451
out = append(out, msg)
448452
}
449
- // thinking blocks are intentionally skipped — too verbose for IRC
453
+ case "thinking":
454
+ if mirrorReasoning {
455
+ for _, l := range splitMirrorText(block.Text) {
456
+ if l != "" {
457
+ out = append(out, "💭 "+sanitizeSecrets(l))
458
+ }
459
+ }
460
+ }
450461
}
451462
}
452463
return out
453464
}
454465
@@ -577,12 +588,12 @@
577588
return out
578589
}
579590
580591
// --- Relay input (operator → Claude) ---
581592
582
-func relayInputLoop(ctx context.Context, relay sessionrelay.Connector, cfg config, state *relayState, ptyFile *os.File) {
583
- lastSeen := time.Now()
593
+func relayInputLoop(ctx context.Context, relay sessionrelay.Connector, cfg config, state *relayState, ptyFile *os.File, since time.Time) {
594
+ lastSeen := since
584595
ticker := time.NewTicker(cfg.PollInterval)
585596
defer ticker.Stop()
586597
587598
for {
588599
select {
@@ -818,10 +829,11 @@
818829
IRCPass: getenvOr(fileConfig, "SCUTTLEBOT_IRC_PASS", ""),
819830
IRCAgentType: getenvOr(fileConfig, "SCUTTLEBOT_IRC_AGENT_TYPE", "worker"),
820831
IRCDeleteOnClose: getenvBoolOr(fileConfig, "SCUTTLEBOT_IRC_DELETE_ON_CLOSE", true),
821832
HooksEnabled: getenvBoolOr(fileConfig, "SCUTTLEBOT_HOOKS_ENABLED", true),
822833
InterruptOnMessage: getenvBoolOr(fileConfig, "SCUTTLEBOT_INTERRUPT_ON_MESSAGE", true),
834
+ MirrorReasoning: getenvBoolOr(fileConfig, "SCUTTLEBOT_MIRROR_REASONING", false),
823835
PollInterval: getenvDurationOr(fileConfig, "SCUTTLEBOT_POLL_INTERVAL", defaultPollInterval),
824836
HeartbeatInterval: getenvDurationAllowZeroOr(fileConfig, "SCUTTLEBOT_PRESENCE_HEARTBEAT", defaultHeartbeat),
825837
Args: append([]string(nil), args...),
826838
}
827839
828840
--- cmd/claude-relay/main.go
+++ cmd/claude-relay/main.go
@@ -77,10 +77,11 @@
77 ChannelStateFile string
78 SessionID string
79 Nick string
80 HooksEnabled bool
81 InterruptOnMessage bool
 
82 PollInterval time.Duration
83 HeartbeatInterval time.Duration
84 TargetCWD string
85 Args []string
86 }
@@ -131,10 +132,11 @@
131 _ = sessionrelay.RemoveChannelStateFile(cfg.ChannelStateFile)
132 defer func() { _ = sessionrelay.RemoveChannelStateFile(cfg.ChannelStateFile) }()
133
134 var relay sessionrelay.Connector
135 relayActive := false
 
136 if relayRequested {
137 conn, err := sessionrelay.New(sessionrelay.Config{
138 Transport: cfg.Transport,
139 URL: cfg.URL,
140 Token: cfg.Token,
@@ -159,10 +161,11 @@
159 relay = conn
160 relayActive = true
161 if err := sessionrelay.WriteChannelStateFile(cfg.ChannelStateFile, relay.ControlChannel(), relay.Channels()); err != nil {
162 fmt.Fprintf(os.Stderr, "claude-relay: channel state disabled: %v\n", err)
163 }
 
164 _ = relay.Post(context.Background(), fmt.Sprintf(
165 "online in %s; mention %s to interrupt before the next action",
166 filepath.Base(cfg.TargetCWD), cfg.Nick,
167 ))
168 }
@@ -245,11 +248,11 @@
245 }()
246 go func() {
247 copyPTYOutput(ptmx, os.Stdout, state)
248 }()
249 if relayActive {
250 go relayInputLoop(ctx, relay, cfg, state, ptmx)
251 }
252
253 err = cmd.Wait()
254 cancel()
255
@@ -268,11 +271,11 @@
268 if ctx.Err() == nil {
269 _ = relay.Post(context.Background(), fmt.Sprintf("mirror failed: %v — session activity not visible in IRC", err))
270 }
271 return
272 }
273 if err := tailSessionFile(ctx, sessionPath, func(text string) {
274 for _, line := range splitMirrorText(text) {
275 if line == "" {
276 continue
277 }
278 _ = relay.Post(ctx, line)
@@ -385,11 +388,11 @@
385 return entry.CWD == targetCWD
386 }
387 return false
388 }
389
390 func tailSessionFile(ctx context.Context, path string, emit func(string)) error {
391 file, err := os.Open(path)
392 if err != nil {
393 return err
394 }
395 defer file.Close()
@@ -400,11 +403,11 @@
400
401 reader := bufio.NewReader(file)
402 for {
403 line, err := reader.ReadBytes('\n')
404 if len(line) > 0 {
405 for _, text := range sessionMessages(line) {
406 if text != "" {
407 emit(text)
408 }
409 }
410 }
@@ -422,11 +425,12 @@
422 return err
423 }
424 }
425
426 // sessionMessages parses a Claude Code JSONL line and returns IRC-ready strings.
427 func sessionMessages(line []byte) []string {
 
428 var entry claudeSessionEntry
429 if err := json.Unmarshal(line, &entry); err != nil {
430 return nil
431 }
432 if entry.Type != "assistant" || entry.Message.Role != "assistant" {
@@ -444,11 +448,18 @@
444 }
445 case "tool_use":
446 if msg := summarizeToolUse(block.Name, block.Input); msg != "" {
447 out = append(out, msg)
448 }
449 // thinking blocks are intentionally skipped — too verbose for IRC
 
 
 
 
 
 
 
450 }
451 }
452 return out
453 }
454
@@ -577,12 +588,12 @@
577 return out
578 }
579
580 // --- Relay input (operator → Claude) ---
581
582 func relayInputLoop(ctx context.Context, relay sessionrelay.Connector, cfg config, state *relayState, ptyFile *os.File) {
583 lastSeen := time.Now()
584 ticker := time.NewTicker(cfg.PollInterval)
585 defer ticker.Stop()
586
587 for {
588 select {
@@ -818,10 +829,11 @@
818 IRCPass: getenvOr(fileConfig, "SCUTTLEBOT_IRC_PASS", ""),
819 IRCAgentType: getenvOr(fileConfig, "SCUTTLEBOT_IRC_AGENT_TYPE", "worker"),
820 IRCDeleteOnClose: getenvBoolOr(fileConfig, "SCUTTLEBOT_IRC_DELETE_ON_CLOSE", true),
821 HooksEnabled: getenvBoolOr(fileConfig, "SCUTTLEBOT_HOOKS_ENABLED", true),
822 InterruptOnMessage: getenvBoolOr(fileConfig, "SCUTTLEBOT_INTERRUPT_ON_MESSAGE", true),
 
823 PollInterval: getenvDurationOr(fileConfig, "SCUTTLEBOT_POLL_INTERVAL", defaultPollInterval),
824 HeartbeatInterval: getenvDurationAllowZeroOr(fileConfig, "SCUTTLEBOT_PRESENCE_HEARTBEAT", defaultHeartbeat),
825 Args: append([]string(nil), args...),
826 }
827
828
--- cmd/claude-relay/main.go
+++ cmd/claude-relay/main.go
@@ -77,10 +77,11 @@
77 ChannelStateFile string
78 SessionID string
79 Nick string
80 HooksEnabled bool
81 InterruptOnMessage bool
82 MirrorReasoning bool
83 PollInterval time.Duration
84 HeartbeatInterval time.Duration
85 TargetCWD string
86 Args []string
87 }
@@ -131,10 +132,11 @@
132 _ = sessionrelay.RemoveChannelStateFile(cfg.ChannelStateFile)
133 defer func() { _ = sessionrelay.RemoveChannelStateFile(cfg.ChannelStateFile) }()
134
135 var relay sessionrelay.Connector
136 relayActive := false
137 var onlineAt time.Time
138 if relayRequested {
139 conn, err := sessionrelay.New(sessionrelay.Config{
140 Transport: cfg.Transport,
141 URL: cfg.URL,
142 Token: cfg.Token,
@@ -159,10 +161,11 @@
161 relay = conn
162 relayActive = true
163 if err := sessionrelay.WriteChannelStateFile(cfg.ChannelStateFile, relay.ControlChannel(), relay.Channels()); err != nil {
164 fmt.Fprintf(os.Stderr, "claude-relay: channel state disabled: %v\n", err)
165 }
166 onlineAt = time.Now()
167 _ = relay.Post(context.Background(), fmt.Sprintf(
168 "online in %s; mention %s to interrupt before the next action",
169 filepath.Base(cfg.TargetCWD), cfg.Nick,
170 ))
171 }
@@ -245,11 +248,11 @@
248 }()
249 go func() {
250 copyPTYOutput(ptmx, os.Stdout, state)
251 }()
252 if relayActive {
253 go relayInputLoop(ctx, relay, cfg, state, ptmx, onlineAt)
254 }
255
256 err = cmd.Wait()
257 cancel()
258
@@ -268,11 +271,11 @@
271 if ctx.Err() == nil {
272 _ = relay.Post(context.Background(), fmt.Sprintf("mirror failed: %v — session activity not visible in IRC", err))
273 }
274 return
275 }
276 if err := tailSessionFile(ctx, sessionPath, cfg.MirrorReasoning, func(text string) {
277 for _, line := range splitMirrorText(text) {
278 if line == "" {
279 continue
280 }
281 _ = relay.Post(ctx, line)
@@ -385,11 +388,11 @@
388 return entry.CWD == targetCWD
389 }
390 return false
391 }
392
393 func tailSessionFile(ctx context.Context, path string, mirrorReasoning bool, emit func(string)) error {
394 file, err := os.Open(path)
395 if err != nil {
396 return err
397 }
398 defer file.Close()
@@ -400,11 +403,11 @@
403
404 reader := bufio.NewReader(file)
405 for {
406 line, err := reader.ReadBytes('\n')
407 if len(line) > 0 {
408 for _, text := range sessionMessages(line, mirrorReasoning) {
409 if text != "" {
410 emit(text)
411 }
412 }
413 }
@@ -422,11 +425,12 @@
425 return err
426 }
427 }
428
429 // sessionMessages parses a Claude Code JSONL line and returns IRC-ready strings.
430 // If mirrorReasoning is true, thinking blocks are included prefixed with "💭 ".
431 func sessionMessages(line []byte, mirrorReasoning bool) []string {
432 var entry claudeSessionEntry
433 if err := json.Unmarshal(line, &entry); err != nil {
434 return nil
435 }
436 if entry.Type != "assistant" || entry.Message.Role != "assistant" {
@@ -444,11 +448,18 @@
448 }
449 case "tool_use":
450 if msg := summarizeToolUse(block.Name, block.Input); msg != "" {
451 out = append(out, msg)
452 }
453 case "thinking":
454 if mirrorReasoning {
455 for _, l := range splitMirrorText(block.Text) {
456 if l != "" {
457 out = append(out, "💭 "+sanitizeSecrets(l))
458 }
459 }
460 }
461 }
462 }
463 return out
464 }
465
@@ -577,12 +588,12 @@
588 return out
589 }
590
591 // --- Relay input (operator → Claude) ---
592
593 func relayInputLoop(ctx context.Context, relay sessionrelay.Connector, cfg config, state *relayState, ptyFile *os.File, since time.Time) {
594 lastSeen := since
595 ticker := time.NewTicker(cfg.PollInterval)
596 defer ticker.Stop()
597
598 for {
599 select {
@@ -818,10 +829,11 @@
829 IRCPass: getenvOr(fileConfig, "SCUTTLEBOT_IRC_PASS", ""),
830 IRCAgentType: getenvOr(fileConfig, "SCUTTLEBOT_IRC_AGENT_TYPE", "worker"),
831 IRCDeleteOnClose: getenvBoolOr(fileConfig, "SCUTTLEBOT_IRC_DELETE_ON_CLOSE", true),
832 HooksEnabled: getenvBoolOr(fileConfig, "SCUTTLEBOT_HOOKS_ENABLED", true),
833 InterruptOnMessage: getenvBoolOr(fileConfig, "SCUTTLEBOT_INTERRUPT_ON_MESSAGE", true),
834 MirrorReasoning: getenvBoolOr(fileConfig, "SCUTTLEBOT_MIRROR_REASONING", false),
835 PollInterval: getenvDurationOr(fileConfig, "SCUTTLEBOT_POLL_INTERVAL", defaultPollInterval),
836 HeartbeatInterval: getenvDurationAllowZeroOr(fileConfig, "SCUTTLEBOT_PRESENCE_HEARTBEAT", defaultHeartbeat),
837 Args: append([]string(nil), args...),
838 }
839
840
--- cmd/claude-relay/main_test.go
+++ cmd/claude-relay/main_test.go
@@ -48,5 +48,21 @@
4848
}
4949
if cfg.Nick != "claude-scuttlebot-abc" {
5050
t.Errorf("expected nick claude-scuttlebot-abc, got %s", cfg.Nick)
5151
}
5252
}
53
+
54
+func TestSessionMessagesThinking(t *testing.T) {
55
+ line := []byte(`{"type":"assistant","message":{"role":"assistant","content":[{"type":"thinking","text":"reasoning here"},{"type":"text","text":"final answer"}]}}`)
56
+
57
+ // thinking off — only text
58
+ got := sessionMessages(line, false)
59
+ if len(got) != 1 || got[0] != "final answer" {
60
+ t.Fatalf("mirrorReasoning=false: got %#v", got)
61
+ }
62
+
63
+ // thinking on — both, thinking prefixed
64
+ got = sessionMessages(line, true)
65
+ if len(got) != 2 || got[0] != "💭 reasoning here" || got[1] != "final answer" {
66
+ t.Fatalf("mirrorReasoning=true: got %#v", got)
67
+ }
68
+}
5369
--- cmd/claude-relay/main_test.go
+++ cmd/claude-relay/main_test.go
@@ -48,5 +48,21 @@
48 }
49 if cfg.Nick != "claude-scuttlebot-abc" {
50 t.Errorf("expected nick claude-scuttlebot-abc, got %s", cfg.Nick)
51 }
52 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
--- cmd/claude-relay/main_test.go
+++ cmd/claude-relay/main_test.go
@@ -48,5 +48,21 @@
48 }
49 if cfg.Nick != "claude-scuttlebot-abc" {
50 t.Errorf("expected nick claude-scuttlebot-abc, got %s", cfg.Nick)
51 }
52 }
53
54 func TestSessionMessagesThinking(t *testing.T) {
55 line := []byte(`{"type":"assistant","message":{"role":"assistant","content":[{"type":"thinking","text":"reasoning here"},{"type":"text","text":"final answer"}]}}`)
56
57 // thinking off — only text
58 got := sessionMessages(line, false)
59 if len(got) != 1 || got[0] != "final answer" {
60 t.Fatalf("mirrorReasoning=false: got %#v", got)
61 }
62
63 // thinking on — both, thinking prefixed
64 got = sessionMessages(line, true)
65 if len(got) != 2 || got[0] != "💭 reasoning here" || got[1] != "final answer" {
66 t.Fatalf("mirrorReasoning=true: got %#v", got)
67 }
68 }
69
--- cmd/codex-relay/main.go
+++ cmd/codex-relay/main.go
@@ -77,10 +77,11 @@
7777
ChannelStateFile string
7878
SessionID string
7979
Nick string
8080
HooksEnabled bool
8181
InterruptOnMessage bool
82
+ MirrorReasoning bool
8283
PollInterval time.Duration
8384
HeartbeatInterval time.Duration
8485
TargetCWD string
8586
Args []string
8687
}
@@ -152,10 +153,11 @@
152153
_ = sessionrelay.RemoveChannelStateFile(cfg.ChannelStateFile)
153154
defer func() { _ = sessionrelay.RemoveChannelStateFile(cfg.ChannelStateFile) }()
154155
155156
var relay sessionrelay.Connector
156157
relayActive := false
158
+ var onlineAt time.Time
157159
if relayRequested {
158160
conn, err := sessionrelay.New(sessionrelay.Config{
159161
Transport: cfg.Transport,
160162
URL: cfg.URL,
161163
Token: cfg.Token,
@@ -180,10 +182,11 @@
180182
relay = conn
181183
relayActive = true
182184
if err := sessionrelay.WriteChannelStateFile(cfg.ChannelStateFile, relay.ControlChannel(), relay.Channels()); err != nil {
183185
fmt.Fprintf(os.Stderr, "codex-relay: channel state disabled: %v\n", err)
184186
}
187
+ onlineAt = time.Now()
185188
_ = relay.Post(context.Background(), fmt.Sprintf(
186189
"online in %s; mention %s to interrupt before the next action",
187190
filepath.Base(cfg.TargetCWD), cfg.Nick,
188191
))
189192
}
@@ -266,11 +269,11 @@
266269
}()
267270
go func() {
268271
copyPTYOutput(ptmx, os.Stdout, state)
269272
}()
270273
if relayActive {
271
- go relayInputLoop(ctx, relay, cfg, state, ptmx)
274
+ go relayInputLoop(ctx, relay, cfg, state, ptmx, onlineAt)
272275
}
273276
274277
err = cmd.Wait()
275278
cancel()
276279
@@ -279,12 +282,12 @@
279282
_ = relay.Post(context.Background(), fmt.Sprintf("offline (exit %d)", exitCode))
280283
}
281284
return err
282285
}
283286
284
-func relayInputLoop(ctx context.Context, relay sessionrelay.Connector, cfg config, state *relayState, ptyFile *os.File) {
285
- lastSeen := time.Now()
287
+func relayInputLoop(ctx context.Context, relay sessionrelay.Connector, cfg config, state *relayState, ptyFile *os.File, since time.Time) {
288
+ lastSeen := since
286289
ticker := time.NewTicker(cfg.PollInterval)
287290
defer ticker.Stop()
288291
289292
for {
290293
select {
@@ -517,10 +520,11 @@
517520
IRCPass: getenvOr(fileConfig, "SCUTTLEBOT_IRC_PASS", ""),
518521
IRCAgentType: getenvOr(fileConfig, "SCUTTLEBOT_IRC_AGENT_TYPE", "worker"),
519522
IRCDeleteOnClose: getenvBoolOr(fileConfig, "SCUTTLEBOT_IRC_DELETE_ON_CLOSE", true),
520523
HooksEnabled: getenvBoolOr(fileConfig, "SCUTTLEBOT_HOOKS_ENABLED", true),
521524
InterruptOnMessage: getenvBoolOr(fileConfig, "SCUTTLEBOT_INTERRUPT_ON_MESSAGE", true),
525
+ MirrorReasoning: getenvBoolOr(fileConfig, "SCUTTLEBOT_MIRROR_REASONING", false),
522526
PollInterval: getenvDurationOr(fileConfig, "SCUTTLEBOT_POLL_INTERVAL", defaultPollInterval),
523527
HeartbeatInterval: getenvDurationAllowZeroOr(fileConfig, "SCUTTLEBOT_PRESENCE_HEARTBEAT", defaultHeartbeat),
524528
Args: append([]string(nil), args...),
525529
}
526530
@@ -720,11 +724,11 @@
720724
if ctx.Err() == nil {
721725
_ = relay.Post(context.Background(), fmt.Sprintf("mirror failed: %v — session activity not visible in IRC", err))
722726
}
723727
return
724728
}
725
- if err := tailSessionFile(ctx, sessionPath, func(text string) {
729
+ if err := tailSessionFile(ctx, sessionPath, cfg.MirrorReasoning, func(text string) {
726730
for _, line := range splitMirrorText(text) {
727731
if line == "" {
728732
continue
729733
}
730734
_ = relay.Post(ctx, line)
@@ -770,11 +774,11 @@
770774
case <-ticker.C:
771775
}
772776
}
773777
}
774778
775
-func tailSessionFile(ctx context.Context, path string, emit func(string)) error {
779
+func tailSessionFile(ctx context.Context, path string, mirrorReasoning bool, emit func(string)) error {
776780
file, err := os.Open(path)
777781
if err != nil {
778782
return err
779783
}
780784
defer file.Close()
@@ -785,11 +789,11 @@
785789
786790
reader := bufio.NewReader(file)
787791
for {
788792
line, err := reader.ReadBytes('\n')
789793
if len(line) > 0 {
790
- for _, text := range sessionMessages(line) {
794
+ for _, text := range sessionMessages(line, mirrorReasoning) {
791795
if text != "" {
792796
emit(text)
793797
}
794798
}
795799
}
@@ -806,11 +810,11 @@
806810
}
807811
return err
808812
}
809813
}
810814
811
-func sessionMessages(line []byte) []string {
815
+func sessionMessages(line []byte, mirrorReasoning bool) []string {
812816
var env sessionEnvelope
813817
if err := json.Unmarshal(line, &env); err != nil {
814818
return nil
815819
}
816820
if env.Type != "response_item" {
@@ -833,11 +837,11 @@
833837
}
834838
case "message":
835839
if payload.Role != "assistant" {
836840
return nil
837841
}
838
- return flattenAssistantContent(payload.Content)
842
+ return flattenAssistantContent(payload.Content, mirrorReasoning)
839843
}
840844
return nil
841845
}
842846
843847
func summarizeFunctionCall(name, argsJSON string) string {
@@ -883,19 +887,27 @@
883887
}
884888
return name
885889
}
886890
}
887891
888
-func flattenAssistantContent(content []sessionContent) []string {
892
+func flattenAssistantContent(content []sessionContent, mirrorReasoning bool) []string {
889893
var lines []string
890894
for _, item := range content {
891
- if item.Type != "output_text" {
892
- continue
893
- }
894
- for _, line := range splitMirrorText(item.Text) {
895
- if line != "" {
896
- lines = append(lines, line)
895
+ switch item.Type {
896
+ case "output_text":
897
+ for _, line := range splitMirrorText(item.Text) {
898
+ if line != "" {
899
+ lines = append(lines, line)
900
+ }
901
+ }
902
+ case "reasoning":
903
+ if mirrorReasoning {
904
+ for _, line := range splitMirrorText(item.Text) {
905
+ if line != "" {
906
+ lines = append(lines, "💭 "+line)
907
+ }
908
+ }
897909
}
898910
}
899911
}
900912
return lines
901913
}
902914
--- cmd/codex-relay/main.go
+++ cmd/codex-relay/main.go
@@ -77,10 +77,11 @@
77 ChannelStateFile string
78 SessionID string
79 Nick string
80 HooksEnabled bool
81 InterruptOnMessage bool
 
82 PollInterval time.Duration
83 HeartbeatInterval time.Duration
84 TargetCWD string
85 Args []string
86 }
@@ -152,10 +153,11 @@
152 _ = sessionrelay.RemoveChannelStateFile(cfg.ChannelStateFile)
153 defer func() { _ = sessionrelay.RemoveChannelStateFile(cfg.ChannelStateFile) }()
154
155 var relay sessionrelay.Connector
156 relayActive := false
 
157 if relayRequested {
158 conn, err := sessionrelay.New(sessionrelay.Config{
159 Transport: cfg.Transport,
160 URL: cfg.URL,
161 Token: cfg.Token,
@@ -180,10 +182,11 @@
180 relay = conn
181 relayActive = true
182 if err := sessionrelay.WriteChannelStateFile(cfg.ChannelStateFile, relay.ControlChannel(), relay.Channels()); err != nil {
183 fmt.Fprintf(os.Stderr, "codex-relay: channel state disabled: %v\n", err)
184 }
 
185 _ = relay.Post(context.Background(), fmt.Sprintf(
186 "online in %s; mention %s to interrupt before the next action",
187 filepath.Base(cfg.TargetCWD), cfg.Nick,
188 ))
189 }
@@ -266,11 +269,11 @@
266 }()
267 go func() {
268 copyPTYOutput(ptmx, os.Stdout, state)
269 }()
270 if relayActive {
271 go relayInputLoop(ctx, relay, cfg, state, ptmx)
272 }
273
274 err = cmd.Wait()
275 cancel()
276
@@ -279,12 +282,12 @@
279 _ = relay.Post(context.Background(), fmt.Sprintf("offline (exit %d)", exitCode))
280 }
281 return err
282 }
283
284 func relayInputLoop(ctx context.Context, relay sessionrelay.Connector, cfg config, state *relayState, ptyFile *os.File) {
285 lastSeen := time.Now()
286 ticker := time.NewTicker(cfg.PollInterval)
287 defer ticker.Stop()
288
289 for {
290 select {
@@ -517,10 +520,11 @@
517 IRCPass: getenvOr(fileConfig, "SCUTTLEBOT_IRC_PASS", ""),
518 IRCAgentType: getenvOr(fileConfig, "SCUTTLEBOT_IRC_AGENT_TYPE", "worker"),
519 IRCDeleteOnClose: getenvBoolOr(fileConfig, "SCUTTLEBOT_IRC_DELETE_ON_CLOSE", true),
520 HooksEnabled: getenvBoolOr(fileConfig, "SCUTTLEBOT_HOOKS_ENABLED", true),
521 InterruptOnMessage: getenvBoolOr(fileConfig, "SCUTTLEBOT_INTERRUPT_ON_MESSAGE", true),
 
522 PollInterval: getenvDurationOr(fileConfig, "SCUTTLEBOT_POLL_INTERVAL", defaultPollInterval),
523 HeartbeatInterval: getenvDurationAllowZeroOr(fileConfig, "SCUTTLEBOT_PRESENCE_HEARTBEAT", defaultHeartbeat),
524 Args: append([]string(nil), args...),
525 }
526
@@ -720,11 +724,11 @@
720 if ctx.Err() == nil {
721 _ = relay.Post(context.Background(), fmt.Sprintf("mirror failed: %v — session activity not visible in IRC", err))
722 }
723 return
724 }
725 if err := tailSessionFile(ctx, sessionPath, func(text string) {
726 for _, line := range splitMirrorText(text) {
727 if line == "" {
728 continue
729 }
730 _ = relay.Post(ctx, line)
@@ -770,11 +774,11 @@
770 case <-ticker.C:
771 }
772 }
773 }
774
775 func tailSessionFile(ctx context.Context, path string, emit func(string)) error {
776 file, err := os.Open(path)
777 if err != nil {
778 return err
779 }
780 defer file.Close()
@@ -785,11 +789,11 @@
785
786 reader := bufio.NewReader(file)
787 for {
788 line, err := reader.ReadBytes('\n')
789 if len(line) > 0 {
790 for _, text := range sessionMessages(line) {
791 if text != "" {
792 emit(text)
793 }
794 }
795 }
@@ -806,11 +810,11 @@
806 }
807 return err
808 }
809 }
810
811 func sessionMessages(line []byte) []string {
812 var env sessionEnvelope
813 if err := json.Unmarshal(line, &env); err != nil {
814 return nil
815 }
816 if env.Type != "response_item" {
@@ -833,11 +837,11 @@
833 }
834 case "message":
835 if payload.Role != "assistant" {
836 return nil
837 }
838 return flattenAssistantContent(payload.Content)
839 }
840 return nil
841 }
842
843 func summarizeFunctionCall(name, argsJSON string) string {
@@ -883,19 +887,27 @@
883 }
884 return name
885 }
886 }
887
888 func flattenAssistantContent(content []sessionContent) []string {
889 var lines []string
890 for _, item := range content {
891 if item.Type != "output_text" {
892 continue
893 }
894 for _, line := range splitMirrorText(item.Text) {
895 if line != "" {
896 lines = append(lines, line)
 
 
 
 
 
 
 
 
897 }
898 }
899 }
900 return lines
901 }
902
--- cmd/codex-relay/main.go
+++ cmd/codex-relay/main.go
@@ -77,10 +77,11 @@
77 ChannelStateFile string
78 SessionID string
79 Nick string
80 HooksEnabled bool
81 InterruptOnMessage bool
82 MirrorReasoning bool
83 PollInterval time.Duration
84 HeartbeatInterval time.Duration
85 TargetCWD string
86 Args []string
87 }
@@ -152,10 +153,11 @@
153 _ = sessionrelay.RemoveChannelStateFile(cfg.ChannelStateFile)
154 defer func() { _ = sessionrelay.RemoveChannelStateFile(cfg.ChannelStateFile) }()
155
156 var relay sessionrelay.Connector
157 relayActive := false
158 var onlineAt time.Time
159 if relayRequested {
160 conn, err := sessionrelay.New(sessionrelay.Config{
161 Transport: cfg.Transport,
162 URL: cfg.URL,
163 Token: cfg.Token,
@@ -180,10 +182,11 @@
182 relay = conn
183 relayActive = true
184 if err := sessionrelay.WriteChannelStateFile(cfg.ChannelStateFile, relay.ControlChannel(), relay.Channels()); err != nil {
185 fmt.Fprintf(os.Stderr, "codex-relay: channel state disabled: %v\n", err)
186 }
187 onlineAt = time.Now()
188 _ = relay.Post(context.Background(), fmt.Sprintf(
189 "online in %s; mention %s to interrupt before the next action",
190 filepath.Base(cfg.TargetCWD), cfg.Nick,
191 ))
192 }
@@ -266,11 +269,11 @@
269 }()
270 go func() {
271 copyPTYOutput(ptmx, os.Stdout, state)
272 }()
273 if relayActive {
274 go relayInputLoop(ctx, relay, cfg, state, ptmx, onlineAt)
275 }
276
277 err = cmd.Wait()
278 cancel()
279
@@ -279,12 +282,12 @@
282 _ = relay.Post(context.Background(), fmt.Sprintf("offline (exit %d)", exitCode))
283 }
284 return err
285 }
286
287 func relayInputLoop(ctx context.Context, relay sessionrelay.Connector, cfg config, state *relayState, ptyFile *os.File, since time.Time) {
288 lastSeen := since
289 ticker := time.NewTicker(cfg.PollInterval)
290 defer ticker.Stop()
291
292 for {
293 select {
@@ -517,10 +520,11 @@
520 IRCPass: getenvOr(fileConfig, "SCUTTLEBOT_IRC_PASS", ""),
521 IRCAgentType: getenvOr(fileConfig, "SCUTTLEBOT_IRC_AGENT_TYPE", "worker"),
522 IRCDeleteOnClose: getenvBoolOr(fileConfig, "SCUTTLEBOT_IRC_DELETE_ON_CLOSE", true),
523 HooksEnabled: getenvBoolOr(fileConfig, "SCUTTLEBOT_HOOKS_ENABLED", true),
524 InterruptOnMessage: getenvBoolOr(fileConfig, "SCUTTLEBOT_INTERRUPT_ON_MESSAGE", true),
525 MirrorReasoning: getenvBoolOr(fileConfig, "SCUTTLEBOT_MIRROR_REASONING", false),
526 PollInterval: getenvDurationOr(fileConfig, "SCUTTLEBOT_POLL_INTERVAL", defaultPollInterval),
527 HeartbeatInterval: getenvDurationAllowZeroOr(fileConfig, "SCUTTLEBOT_PRESENCE_HEARTBEAT", defaultHeartbeat),
528 Args: append([]string(nil), args...),
529 }
530
@@ -720,11 +724,11 @@
724 if ctx.Err() == nil {
725 _ = relay.Post(context.Background(), fmt.Sprintf("mirror failed: %v — session activity not visible in IRC", err))
726 }
727 return
728 }
729 if err := tailSessionFile(ctx, sessionPath, cfg.MirrorReasoning, func(text string) {
730 for _, line := range splitMirrorText(text) {
731 if line == "" {
732 continue
733 }
734 _ = relay.Post(ctx, line)
@@ -770,11 +774,11 @@
774 case <-ticker.C:
775 }
776 }
777 }
778
779 func tailSessionFile(ctx context.Context, path string, mirrorReasoning bool, emit func(string)) error {
780 file, err := os.Open(path)
781 if err != nil {
782 return err
783 }
784 defer file.Close()
@@ -785,11 +789,11 @@
789
790 reader := bufio.NewReader(file)
791 for {
792 line, err := reader.ReadBytes('\n')
793 if len(line) > 0 {
794 for _, text := range sessionMessages(line, mirrorReasoning) {
795 if text != "" {
796 emit(text)
797 }
798 }
799 }
@@ -806,11 +810,11 @@
810 }
811 return err
812 }
813 }
814
815 func sessionMessages(line []byte, mirrorReasoning bool) []string {
816 var env sessionEnvelope
817 if err := json.Unmarshal(line, &env); err != nil {
818 return nil
819 }
820 if env.Type != "response_item" {
@@ -833,11 +837,11 @@
837 }
838 case "message":
839 if payload.Role != "assistant" {
840 return nil
841 }
842 return flattenAssistantContent(payload.Content, mirrorReasoning)
843 }
844 return nil
845 }
846
847 func summarizeFunctionCall(name, argsJSON string) string {
@@ -883,19 +887,27 @@
887 }
888 return name
889 }
890 }
891
892 func flattenAssistantContent(content []sessionContent, mirrorReasoning bool) []string {
893 var lines []string
894 for _, item := range content {
895 switch item.Type {
896 case "output_text":
897 for _, line := range splitMirrorText(item.Text) {
898 if line != "" {
899 lines = append(lines, line)
900 }
901 }
902 case "reasoning":
903 if mirrorReasoning {
904 for _, line := range splitMirrorText(item.Text) {
905 if line != "" {
906 lines = append(lines, "💭 "+line)
907 }
908 }
909 }
910 }
911 }
912 return lines
913 }
914
--- cmd/codex-relay/main_test.go
+++ cmd/codex-relay/main_test.go
@@ -154,21 +154,37 @@
154154
155155
func TestSessionMessagesFunctionCallAndAssistant(t *testing.T) {
156156
t.Helper()
157157
158158
fnLine := []byte(`{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"pwd\"}"}}`)
159
- got := sessionMessages(fnLine)
159
+ got := sessionMessages(fnLine, false)
160160
if len(got) != 1 || got[0] != "› pwd" {
161161
t.Fatalf("sessionMessages function_call = %#v", got)
162162
}
163163
164164
msgLine := []byte(`{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"one line\nsecond line"}]}}`)
165
- got = sessionMessages(msgLine)
165
+ got = sessionMessages(msgLine, false)
166166
if len(got) != 2 || got[0] != "one line" || got[1] != "second line" {
167167
t.Fatalf("sessionMessages assistant = %#v", got)
168168
}
169169
}
170
+
171
+func TestSessionMessagesReasoning(t *testing.T) {
172
+ line := []byte(`{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"reasoning","text":"thinking hard"},{"type":"output_text","text":"done"}]}}`)
173
+
174
+ // reasoning off — only output_text
175
+ got := sessionMessages(line, false)
176
+ if len(got) != 1 || got[0] != "done" {
177
+ t.Fatalf("mirrorReasoning=false: got %#v", got)
178
+ }
179
+
180
+ // reasoning on — both, reasoning prefixed
181
+ got = sessionMessages(line, true)
182
+ if len(got) != 2 || got[0] != "💭 thinking hard" || got[1] != "done" {
183
+ t.Fatalf("mirrorReasoning=true: got %#v", got)
184
+ }
185
+}
170186
171187
func TestExplicitThreadID(t *testing.T) {
172188
t.Helper()
173189
174190
got := explicitThreadID([]string{"resume", "019d45e1-8328-7261-9a02-5c4304e07724"})
175191
--- cmd/codex-relay/main_test.go
+++ cmd/codex-relay/main_test.go
@@ -154,21 +154,37 @@
154
155 func TestSessionMessagesFunctionCallAndAssistant(t *testing.T) {
156 t.Helper()
157
158 fnLine := []byte(`{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"pwd\"}"}}`)
159 got := sessionMessages(fnLine)
160 if len(got) != 1 || got[0] != "› pwd" {
161 t.Fatalf("sessionMessages function_call = %#v", got)
162 }
163
164 msgLine := []byte(`{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"one line\nsecond line"}]}}`)
165 got = sessionMessages(msgLine)
166 if len(got) != 2 || got[0] != "one line" || got[1] != "second line" {
167 t.Fatalf("sessionMessages assistant = %#v", got)
168 }
169 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
171 func TestExplicitThreadID(t *testing.T) {
172 t.Helper()
173
174 got := explicitThreadID([]string{"resume", "019d45e1-8328-7261-9a02-5c4304e07724"})
175
--- cmd/codex-relay/main_test.go
+++ cmd/codex-relay/main_test.go
@@ -154,21 +154,37 @@
154
155 func TestSessionMessagesFunctionCallAndAssistant(t *testing.T) {
156 t.Helper()
157
158 fnLine := []byte(`{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"pwd\"}"}}`)
159 got := sessionMessages(fnLine, false)
160 if len(got) != 1 || got[0] != "› pwd" {
161 t.Fatalf("sessionMessages function_call = %#v", got)
162 }
163
164 msgLine := []byte(`{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"one line\nsecond line"}]}}`)
165 got = sessionMessages(msgLine, false)
166 if len(got) != 2 || got[0] != "one line" || got[1] != "second line" {
167 t.Fatalf("sessionMessages assistant = %#v", got)
168 }
169 }
170
171 func TestSessionMessagesReasoning(t *testing.T) {
172 line := []byte(`{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"reasoning","text":"thinking hard"},{"type":"output_text","text":"done"}]}}`)
173
174 // reasoning off — only output_text
175 got := sessionMessages(line, false)
176 if len(got) != 1 || got[0] != "done" {
177 t.Fatalf("mirrorReasoning=false: got %#v", got)
178 }
179
180 // reasoning on — both, reasoning prefixed
181 got = sessionMessages(line, true)
182 if len(got) != 2 || got[0] != "💭 thinking hard" || got[1] != "done" {
183 t.Fatalf("mirrorReasoning=true: got %#v", got)
184 }
185 }
186
187 func TestExplicitThreadID(t *testing.T) {
188 t.Helper()
189
190 got := explicitThreadID([]string{"resume", "019d45e1-8328-7261-9a02-5c4304e07724"})
191

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button