ScuttleBot

Merge pull request #61 from ConflictHQ/feature/57-structured-message-metadata feat: structured message metadata — rich rendering for agent output

noreply 2026-04-04 19:57 trunk merge
Commit f3c383e5b3dd8664db52b8972c7ec2bdf3a0cd5c2f7e7cd9a06a585c80353524
--- a/.scuttlebot.yaml
+++ b/.scuttlebot.yaml
@@ -0,0 +1 @@
1
+channel: scuttlebot
--- a/.scuttlebot.yaml
+++ b/.scuttlebot.yaml
@@ -0,0 +1 @@
 
--- a/.scuttlebot.yaml
+++ b/.scuttlebot.yaml
@@ -0,0 +1 @@
1 channel: scuttlebot
--- cmd/claude-relay/main.go
+++ cmd/claude-relay/main.go
@@ -20,10 +20,11 @@
2020
"time"
2121
2222
"github.com/conflicthq/scuttlebot/pkg/ircagent"
2323
"github.com/conflicthq/scuttlebot/pkg/sessionrelay"
2424
"github.com/creack/pty"
25
+ "github.com/google/uuid"
2526
"golang.org/x/term"
2627
"gopkg.in/yaml.v3"
2728
)
2829
2930
const (
@@ -75,10 +76,11 @@
7576
IRCDeleteOnClose bool
7677
Channel string
7778
Channels []string
7879
ChannelStateFile string
7980
SessionID string
81
+ ClaudeSessionID string // UUID passed to Claude Code via --session-id
8082
Nick string
8183
HooksEnabled bool
8284
InterruptOnMessage bool
8385
MirrorReasoning bool
8486
PollInterval time.Duration
@@ -86,10 +88,16 @@
8688
TargetCWD string
8789
Args []string
8890
}
8991
9092
type message = sessionrelay.Message
93
+
94
+// mirrorLine is a single line of relay output with optional structured metadata.
95
+type mirrorLine struct {
96
+ Text string
97
+ Meta json.RawMessage // nil for plain text lines
98
+}
9199
92100
type relayState struct {
93101
mu sync.RWMutex
94102
lastBusy time.Time
95103
}
@@ -180,10 +188,20 @@
180188
_ = relay.Close(closeCtx)
181189
}()
182190
}
183191
184192
startedAt := time.Now()
193
+ // If resuming, extract the session ID from --resume arg. Otherwise use
194
+ // our generated UUID via --session-id for new sessions.
195
+ if resumeID := extractResumeID(cfg.Args); resumeID != "" {
196
+ cfg.ClaudeSessionID = resumeID
197
+ fmt.Fprintf(os.Stderr, "claude-relay: resuming session %s\n", resumeID)
198
+ } else {
199
+ // New session — inject --session-id so the file name is deterministic.
200
+ cfg.Args = append([]string{"--session-id", cfg.ClaudeSessionID}, cfg.Args...)
201
+ fmt.Fprintf(os.Stderr, "claude-relay: new session %s\n", cfg.ClaudeSessionID)
202
+ }
185203
cmd := exec.Command(cfg.ClaudeBin, cfg.Args...)
186204
cmd.Env = append(os.Environ(),
187205
"SCUTTLEBOT_CONFIG_FILE="+cfg.ConfigFile,
188206
"SCUTTLEBOT_URL="+cfg.URL,
189207
"SCUTTLEBOT_TOKEN="+cfg.Token,
@@ -279,16 +297,20 @@
279297
}
280298
// Session not found yet — wait and retry instead of giving up.
281299
time.Sleep(10 * time.Second)
282300
continue
283301
}
284
- if err := tailSessionFile(ctx, sessionPath, cfg.MirrorReasoning, func(text string) {
285
- for _, line := range splitMirrorText(text) {
302
+ if err := tailSessionFile(ctx, sessionPath, cfg.MirrorReasoning, func(ml mirrorLine) {
303
+ for _, line := range splitMirrorText(ml.Text) {
286304
if line == "" {
287305
continue
288306
}
289
- _ = relay.Post(ctx, line)
307
+ if len(ml.Meta) > 0 {
308
+ _ = relay.PostWithMeta(ctx, line, ml.Meta)
309
+ } else {
310
+ _ = relay.Post(ctx, line)
311
+ }
290312
}
291313
}); err != nil && ctx.Err() == nil {
292314
// Tail lost — retry discovery.
293315
time.Sleep(5 * time.Second)
294316
continue
@@ -295,34 +317,53 @@
295317
}
296318
return
297319
}
298320
}
299321
300
-func discoverSessionPath(ctx context.Context, cfg config, startedAt time.Time) (string, error) {
322
+func discoverSessionPath(ctx context.Context, cfg config, _ time.Time) (string, error) {
301323
root, err := claudeSessionsRoot(cfg.TargetCWD)
302324
if err != nil {
303325
return "", err
304326
}
305327
328
+ // We passed --session-id to Claude Code, so the file name is deterministic.
329
+ target := filepath.Join(root, cfg.ClaudeSessionID+".jsonl")
330
+ fmt.Fprintf(os.Stderr, "claude-relay: waiting for session file %s\n", target)
331
+
306332
ctx, cancel := context.WithTimeout(ctx, defaultDiscoverWait)
307333
defer cancel()
308334
309335
ticker := time.NewTicker(defaultScanInterval)
310336
defer ticker.Stop()
311337
312338
for {
313
- path, err := findLatestSessionPath(root, cfg.TargetCWD, startedAt.Add(-2*time.Second))
314
- if err == nil && path != "" {
315
- return path, nil
339
+ if _, err := os.Stat(target); err == nil {
340
+ fmt.Fprintf(os.Stderr, "claude-relay: found session file %s\n", target)
341
+ return target, nil
316342
}
317343
select {
318344
case <-ctx.Done():
319
- return "", ctx.Err()
345
+ return "", fmt.Errorf("session file %s not found after %v", target, defaultDiscoverWait)
320346
case <-ticker.C:
321347
}
322348
}
323349
}
350
+
351
+// extractResumeID finds --resume or -r in args and returns the session UUID
352
+// that follows it. Returns "" if not resuming or if the value isn't a UUID.
353
+func extractResumeID(args []string) string {
354
+ for i := 0; i < len(args)-1; i++ {
355
+ if args[i] == "--resume" || args[i] == "-r" || args[i] == "--continue" {
356
+ val := args[i+1]
357
+ // Must look like a UUID (contains dashes, right length)
358
+ if len(val) >= 32 && strings.Contains(val, "-") {
359
+ return val
360
+ }
361
+ }
362
+ }
363
+ return ""
364
+}
324365
325366
// claudeSessionsRoot returns ~/.claude/projects/<sanitized-cwd>/
326367
func claudeSessionsRoot(cwd string) (string, error) {
327368
home, err := os.UserHomeDir()
328369
if err != nil {
@@ -331,80 +372,11 @@
331372
sanitized := strings.ReplaceAll(cwd, "/", "-")
332373
sanitized = strings.TrimLeft(sanitized, "-")
333374
return filepath.Join(home, ".claude", "projects", "-"+sanitized), nil
334375
}
335376
336
-// findLatestSessionPath finds the most recently modified .jsonl file in root
337
-// that contains an entry with cwd matching targetCWD and timestamp after since.
338
-func findLatestSessionPath(root, targetCWD string, since time.Time) (string, error) {
339
- entries, err := os.ReadDir(root)
340
- if err != nil {
341
- return "", err
342
- }
343
-
344
- type candidate struct {
345
- path string
346
- modTime time.Time
347
- }
348
- var candidates []candidate
349
- for _, e := range entries {
350
- if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {
351
- continue
352
- }
353
- info, err := e.Info()
354
- if err != nil {
355
- continue
356
- }
357
- if info.ModTime().Before(since) {
358
- continue
359
- }
360
- candidates = append(candidates, candidate{
361
- path: filepath.Join(root, e.Name()),
362
- modTime: info.ModTime(),
363
- })
364
- }
365
- if len(candidates) == 0 {
366
- return "", errors.New("no session files found")
367
- }
368
- // Sort newest first.
369
- sort.Slice(candidates, func(i, j int) bool {
370
- return candidates[i].modTime.After(candidates[j].modTime)
371
- })
372
- // Return the first file that has an entry matching our cwd.
373
- for _, c := range candidates {
374
- if matchesSession(c.path, targetCWD, since) {
375
- return c.path, nil
376
- }
377
- }
378
- return "", errors.New("no matching session found")
379
-}
380
-
381
-// matchesSession peeks at the first few lines of a JSONL file to verify cwd.
382
-func matchesSession(path, targetCWD string, since time.Time) bool {
383
- f, err := os.Open(path)
384
- if err != nil {
385
- return false
386
- }
387
- defer f.Close()
388
-
389
- scanner := bufio.NewScanner(f)
390
- checked := 0
391
- for scanner.Scan() && checked < 5 {
392
- checked++
393
- var entry claudeSessionEntry
394
- if err := json.Unmarshal(scanner.Bytes(), &entry); err != nil {
395
- continue
396
- }
397
- if entry.CWD == "" {
398
- continue
399
- }
400
- return entry.CWD == targetCWD
401
- }
402
- return false
403
-}
404
-
405
-func tailSessionFile(ctx context.Context, path string, mirrorReasoning bool, emit func(string)) error {
377
+func tailSessionFile(ctx context.Context, path string, mirrorReasoning bool, emit func(mirrorLine)) error {
406378
file, err := os.Open(path)
407379
if err != nil {
408380
return err
409381
}
410382
defer file.Close()
@@ -415,13 +387,13 @@
415387
416388
reader := bufio.NewReader(file)
417389
for {
418390
line, err := reader.ReadBytes('\n')
419391
if len(line) > 0 {
420
- for _, text := range sessionMessages(line, mirrorReasoning) {
421
- if text != "" {
422
- emit(text)
392
+ for _, ml := range sessionMessages(line, mirrorReasoning) {
393
+ if ml.Text != "" {
394
+ emit(ml)
423395
}
424396
}
425397
}
426398
if err == nil {
427399
continue
@@ -430,52 +402,114 @@
430402
select {
431403
case <-ctx.Done():
432404
return nil
433405
case <-time.After(defaultScanInterval):
434406
}
407
+ // Reset the buffered reader so it retries the underlying
408
+ // file descriptor. bufio.Reader caches EOF and won't see
409
+ // new bytes appended to the file without a reset.
410
+ reader.Reset(file)
435411
continue
436412
}
437413
return err
438414
}
439415
}
440416
441
-// sessionMessages parses a Claude Code JSONL line and returns IRC-ready strings.
417
+// sessionMessages parses a Claude Code JSONL line and returns mirror lines
418
+// with optional structured metadata for rich rendering in the web UI.
442419
// If mirrorReasoning is true, thinking blocks are included prefixed with "💭 ".
443
-func sessionMessages(line []byte, mirrorReasoning bool) []string {
420
+func sessionMessages(line []byte, mirrorReasoning bool) []mirrorLine {
444421
var entry claudeSessionEntry
445422
if err := json.Unmarshal(line, &entry); err != nil {
446423
return nil
447424
}
448425
if entry.Type != "assistant" || entry.Message.Role != "assistant" {
449426
return nil
450427
}
451428
452
- var out []string
429
+ var out []mirrorLine
453430
for _, block := range entry.Message.Content {
454431
switch block.Type {
455432
case "text":
456433
for _, l := range splitMirrorText(block.Text) {
457434
if l != "" {
458
- out = append(out, sanitizeSecrets(l))
435
+ out = append(out, mirrorLine{Text: sanitizeSecrets(l)})
459436
}
460437
}
461438
case "tool_use":
462439
if msg := summarizeToolUse(block.Name, block.Input); msg != "" {
463
- out = append(out, msg)
440
+ out = append(out, mirrorLine{
441
+ Text: msg,
442
+ Meta: toolMeta(block.Name, block.Input),
443
+ })
464444
}
465445
case "thinking":
466446
if mirrorReasoning {
467447
for _, l := range splitMirrorText(block.Text) {
468448
if l != "" {
469
- out = append(out, "💭 "+sanitizeSecrets(l))
449
+ out = append(out, mirrorLine{Text: "💭 " + sanitizeSecrets(l)})
470450
}
471451
}
472452
}
473453
}
474454
}
475455
return out
476456
}
457
+
458
+// toolMeta builds a JSON metadata envelope for a tool_use block.
459
+func toolMeta(name string, inputRaw json.RawMessage) json.RawMessage {
460
+ var input map[string]json.RawMessage
461
+ _ = json.Unmarshal(inputRaw, &input)
462
+
463
+ data := map[string]string{"tool": name}
464
+
465
+ str := func(key string) string {
466
+ v, ok := input[key]
467
+ if !ok {
468
+ return ""
469
+ }
470
+ var s string
471
+ if err := json.Unmarshal(v, &s); err != nil {
472
+ return strings.Trim(string(v), `"`)
473
+ }
474
+ return s
475
+ }
476
+
477
+ switch name {
478
+ case "Bash":
479
+ if cmd := str("command"); cmd != "" {
480
+ data["command"] = sanitizeSecrets(cmd)
481
+ }
482
+ case "Edit", "Write", "Read":
483
+ if p := str("file_path"); p != "" {
484
+ data["file"] = p
485
+ }
486
+ case "Glob":
487
+ if p := str("pattern"); p != "" {
488
+ data["pattern"] = p
489
+ }
490
+ case "Grep":
491
+ if p := str("pattern"); p != "" {
492
+ data["pattern"] = p
493
+ }
494
+ case "WebFetch":
495
+ if u := str("url"); u != "" {
496
+ data["url"] = sanitizeSecrets(u)
497
+ }
498
+ case "WebSearch":
499
+ if q := str("query"); q != "" {
500
+ data["query"] = q
501
+ }
502
+ }
503
+
504
+ meta := map[string]any{
505
+ "type": "tool_result",
506
+ "data": data,
507
+ }
508
+ b, _ := json.Marshal(meta)
509
+ return b
510
+}
477511
478512
func summarizeToolUse(name string, inputRaw json.RawMessage) string {
479513
var input map[string]json.RawMessage
480514
_ = json.Unmarshal(inputRaw, &input)
481515
@@ -945,10 +979,11 @@
945979
sessionID := getenvOr(fileConfig, "SCUTTLEBOT_SESSION_ID", "")
946980
if sessionID == "" {
947981
sessionID = defaultSessionID(target)
948982
}
949983
cfg.SessionID = sanitize(sessionID)
984
+ cfg.ClaudeSessionID = uuid.New().String()
950985
951986
nick := getenvOr(fileConfig, "SCUTTLEBOT_NICK", "")
952987
if nick == "" {
953988
nick = fmt.Sprintf("claude-%s-%s", sanitize(filepath.Base(target)), cfg.SessionID)
954989
}
955990
--- cmd/claude-relay/main.go
+++ cmd/claude-relay/main.go
@@ -20,10 +20,11 @@
20 "time"
21
22 "github.com/conflicthq/scuttlebot/pkg/ircagent"
23 "github.com/conflicthq/scuttlebot/pkg/sessionrelay"
24 "github.com/creack/pty"
 
25 "golang.org/x/term"
26 "gopkg.in/yaml.v3"
27 )
28
29 const (
@@ -75,10 +76,11 @@
75 IRCDeleteOnClose bool
76 Channel string
77 Channels []string
78 ChannelStateFile string
79 SessionID string
 
80 Nick string
81 HooksEnabled bool
82 InterruptOnMessage bool
83 MirrorReasoning bool
84 PollInterval time.Duration
@@ -86,10 +88,16 @@
86 TargetCWD string
87 Args []string
88 }
89
90 type message = sessionrelay.Message
 
 
 
 
 
 
91
92 type relayState struct {
93 mu sync.RWMutex
94 lastBusy time.Time
95 }
@@ -180,10 +188,20 @@
180 _ = relay.Close(closeCtx)
181 }()
182 }
183
184 startedAt := time.Now()
 
 
 
 
 
 
 
 
 
 
185 cmd := exec.Command(cfg.ClaudeBin, cfg.Args...)
186 cmd.Env = append(os.Environ(),
187 "SCUTTLEBOT_CONFIG_FILE="+cfg.ConfigFile,
188 "SCUTTLEBOT_URL="+cfg.URL,
189 "SCUTTLEBOT_TOKEN="+cfg.Token,
@@ -279,16 +297,20 @@
279 }
280 // Session not found yet — wait and retry instead of giving up.
281 time.Sleep(10 * time.Second)
282 continue
283 }
284 if err := tailSessionFile(ctx, sessionPath, cfg.MirrorReasoning, func(text string) {
285 for _, line := range splitMirrorText(text) {
286 if line == "" {
287 continue
288 }
289 _ = relay.Post(ctx, line)
 
 
 
 
290 }
291 }); err != nil && ctx.Err() == nil {
292 // Tail lost — retry discovery.
293 time.Sleep(5 * time.Second)
294 continue
@@ -295,34 +317,53 @@
295 }
296 return
297 }
298 }
299
300 func discoverSessionPath(ctx context.Context, cfg config, startedAt time.Time) (string, error) {
301 root, err := claudeSessionsRoot(cfg.TargetCWD)
302 if err != nil {
303 return "", err
304 }
305
 
 
 
 
306 ctx, cancel := context.WithTimeout(ctx, defaultDiscoverWait)
307 defer cancel()
308
309 ticker := time.NewTicker(defaultScanInterval)
310 defer ticker.Stop()
311
312 for {
313 path, err := findLatestSessionPath(root, cfg.TargetCWD, startedAt.Add(-2*time.Second))
314 if err == nil && path != "" {
315 return path, nil
316 }
317 select {
318 case <-ctx.Done():
319 return "", ctx.Err()
320 case <-ticker.C:
321 }
322 }
323 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
324
325 // claudeSessionsRoot returns ~/.claude/projects/<sanitized-cwd>/
326 func claudeSessionsRoot(cwd string) (string, error) {
327 home, err := os.UserHomeDir()
328 if err != nil {
@@ -331,80 +372,11 @@
331 sanitized := strings.ReplaceAll(cwd, "/", "-")
332 sanitized = strings.TrimLeft(sanitized, "-")
333 return filepath.Join(home, ".claude", "projects", "-"+sanitized), nil
334 }
335
336 // findLatestSessionPath finds the most recently modified .jsonl file in root
337 // that contains an entry with cwd matching targetCWD and timestamp after since.
338 func findLatestSessionPath(root, targetCWD string, since time.Time) (string, error) {
339 entries, err := os.ReadDir(root)
340 if err != nil {
341 return "", err
342 }
343
344 type candidate struct {
345 path string
346 modTime time.Time
347 }
348 var candidates []candidate
349 for _, e := range entries {
350 if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {
351 continue
352 }
353 info, err := e.Info()
354 if err != nil {
355 continue
356 }
357 if info.ModTime().Before(since) {
358 continue
359 }
360 candidates = append(candidates, candidate{
361 path: filepath.Join(root, e.Name()),
362 modTime: info.ModTime(),
363 })
364 }
365 if len(candidates) == 0 {
366 return "", errors.New("no session files found")
367 }
368 // Sort newest first.
369 sort.Slice(candidates, func(i, j int) bool {
370 return candidates[i].modTime.After(candidates[j].modTime)
371 })
372 // Return the first file that has an entry matching our cwd.
373 for _, c := range candidates {
374 if matchesSession(c.path, targetCWD, since) {
375 return c.path, nil
376 }
377 }
378 return "", errors.New("no matching session found")
379 }
380
381 // matchesSession peeks at the first few lines of a JSONL file to verify cwd.
382 func matchesSession(path, targetCWD string, since time.Time) bool {
383 f, err := os.Open(path)
384 if err != nil {
385 return false
386 }
387 defer f.Close()
388
389 scanner := bufio.NewScanner(f)
390 checked := 0
391 for scanner.Scan() && checked < 5 {
392 checked++
393 var entry claudeSessionEntry
394 if err := json.Unmarshal(scanner.Bytes(), &entry); err != nil {
395 continue
396 }
397 if entry.CWD == "" {
398 continue
399 }
400 return entry.CWD == targetCWD
401 }
402 return false
403 }
404
405 func tailSessionFile(ctx context.Context, path string, mirrorReasoning bool, emit func(string)) error {
406 file, err := os.Open(path)
407 if err != nil {
408 return err
409 }
410 defer file.Close()
@@ -415,13 +387,13 @@
415
416 reader := bufio.NewReader(file)
417 for {
418 line, err := reader.ReadBytes('\n')
419 if len(line) > 0 {
420 for _, text := range sessionMessages(line, mirrorReasoning) {
421 if text != "" {
422 emit(text)
423 }
424 }
425 }
426 if err == nil {
427 continue
@@ -430,52 +402,114 @@
430 select {
431 case <-ctx.Done():
432 return nil
433 case <-time.After(defaultScanInterval):
434 }
 
 
 
 
435 continue
436 }
437 return err
438 }
439 }
440
441 // sessionMessages parses a Claude Code JSONL line and returns IRC-ready strings.
 
442 // If mirrorReasoning is true, thinking blocks are included prefixed with "💭 ".
443 func sessionMessages(line []byte, mirrorReasoning bool) []string {
444 var entry claudeSessionEntry
445 if err := json.Unmarshal(line, &entry); err != nil {
446 return nil
447 }
448 if entry.Type != "assistant" || entry.Message.Role != "assistant" {
449 return nil
450 }
451
452 var out []string
453 for _, block := range entry.Message.Content {
454 switch block.Type {
455 case "text":
456 for _, l := range splitMirrorText(block.Text) {
457 if l != "" {
458 out = append(out, sanitizeSecrets(l))
459 }
460 }
461 case "tool_use":
462 if msg := summarizeToolUse(block.Name, block.Input); msg != "" {
463 out = append(out, msg)
 
 
 
464 }
465 case "thinking":
466 if mirrorReasoning {
467 for _, l := range splitMirrorText(block.Text) {
468 if l != "" {
469 out = append(out, "💭 "+sanitizeSecrets(l))
470 }
471 }
472 }
473 }
474 }
475 return out
476 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
477
478 func summarizeToolUse(name string, inputRaw json.RawMessage) string {
479 var input map[string]json.RawMessage
480 _ = json.Unmarshal(inputRaw, &input)
481
@@ -945,10 +979,11 @@
945 sessionID := getenvOr(fileConfig, "SCUTTLEBOT_SESSION_ID", "")
946 if sessionID == "" {
947 sessionID = defaultSessionID(target)
948 }
949 cfg.SessionID = sanitize(sessionID)
 
950
951 nick := getenvOr(fileConfig, "SCUTTLEBOT_NICK", "")
952 if nick == "" {
953 nick = fmt.Sprintf("claude-%s-%s", sanitize(filepath.Base(target)), cfg.SessionID)
954 }
955
--- cmd/claude-relay/main.go
+++ cmd/claude-relay/main.go
@@ -20,10 +20,11 @@
20 "time"
21
22 "github.com/conflicthq/scuttlebot/pkg/ircagent"
23 "github.com/conflicthq/scuttlebot/pkg/sessionrelay"
24 "github.com/creack/pty"
25 "github.com/google/uuid"
26 "golang.org/x/term"
27 "gopkg.in/yaml.v3"
28 )
29
30 const (
@@ -75,10 +76,11 @@
76 IRCDeleteOnClose bool
77 Channel string
78 Channels []string
79 ChannelStateFile string
80 SessionID string
81 ClaudeSessionID string // UUID passed to Claude Code via --session-id
82 Nick string
83 HooksEnabled bool
84 InterruptOnMessage bool
85 MirrorReasoning bool
86 PollInterval time.Duration
@@ -86,10 +88,16 @@
88 TargetCWD string
89 Args []string
90 }
91
92 type message = sessionrelay.Message
93
94 // mirrorLine is a single line of relay output with optional structured metadata.
95 type mirrorLine struct {
96 Text string
97 Meta json.RawMessage // nil for plain text lines
98 }
99
100 type relayState struct {
101 mu sync.RWMutex
102 lastBusy time.Time
103 }
@@ -180,10 +188,20 @@
188 _ = relay.Close(closeCtx)
189 }()
190 }
191
192 startedAt := time.Now()
193 // If resuming, extract the session ID from --resume arg. Otherwise use
194 // our generated UUID via --session-id for new sessions.
195 if resumeID := extractResumeID(cfg.Args); resumeID != "" {
196 cfg.ClaudeSessionID = resumeID
197 fmt.Fprintf(os.Stderr, "claude-relay: resuming session %s\n", resumeID)
198 } else {
199 // New session — inject --session-id so the file name is deterministic.
200 cfg.Args = append([]string{"--session-id", cfg.ClaudeSessionID}, cfg.Args...)
201 fmt.Fprintf(os.Stderr, "claude-relay: new session %s\n", cfg.ClaudeSessionID)
202 }
203 cmd := exec.Command(cfg.ClaudeBin, cfg.Args...)
204 cmd.Env = append(os.Environ(),
205 "SCUTTLEBOT_CONFIG_FILE="+cfg.ConfigFile,
206 "SCUTTLEBOT_URL="+cfg.URL,
207 "SCUTTLEBOT_TOKEN="+cfg.Token,
@@ -279,16 +297,20 @@
297 }
298 // Session not found yet — wait and retry instead of giving up.
299 time.Sleep(10 * time.Second)
300 continue
301 }
302 if err := tailSessionFile(ctx, sessionPath, cfg.MirrorReasoning, func(ml mirrorLine) {
303 for _, line := range splitMirrorText(ml.Text) {
304 if line == "" {
305 continue
306 }
307 if len(ml.Meta) > 0 {
308 _ = relay.PostWithMeta(ctx, line, ml.Meta)
309 } else {
310 _ = relay.Post(ctx, line)
311 }
312 }
313 }); err != nil && ctx.Err() == nil {
314 // Tail lost — retry discovery.
315 time.Sleep(5 * time.Second)
316 continue
@@ -295,34 +317,53 @@
317 }
318 return
319 }
320 }
321
322 func discoverSessionPath(ctx context.Context, cfg config, _ time.Time) (string, error) {
323 root, err := claudeSessionsRoot(cfg.TargetCWD)
324 if err != nil {
325 return "", err
326 }
327
328 // We passed --session-id to Claude Code, so the file name is deterministic.
329 target := filepath.Join(root, cfg.ClaudeSessionID+".jsonl")
330 fmt.Fprintf(os.Stderr, "claude-relay: waiting for session file %s\n", target)
331
332 ctx, cancel := context.WithTimeout(ctx, defaultDiscoverWait)
333 defer cancel()
334
335 ticker := time.NewTicker(defaultScanInterval)
336 defer ticker.Stop()
337
338 for {
339 if _, err := os.Stat(target); err == nil {
340 fmt.Fprintf(os.Stderr, "claude-relay: found session file %s\n", target)
341 return target, nil
342 }
343 select {
344 case <-ctx.Done():
345 return "", fmt.Errorf("session file %s not found after %v", target, defaultDiscoverWait)
346 case <-ticker.C:
347 }
348 }
349 }
350
351 // extractResumeID finds --resume or -r in args and returns the session UUID
352 // that follows it. Returns "" if not resuming or if the value isn't a UUID.
353 func extractResumeID(args []string) string {
354 for i := 0; i < len(args)-1; i++ {
355 if args[i] == "--resume" || args[i] == "-r" || args[i] == "--continue" {
356 val := args[i+1]
357 // Must look like a UUID (contains dashes, right length)
358 if len(val) >= 32 && strings.Contains(val, "-") {
359 return val
360 }
361 }
362 }
363 return ""
364 }
365
366 // claudeSessionsRoot returns ~/.claude/projects/<sanitized-cwd>/
367 func claudeSessionsRoot(cwd string) (string, error) {
368 home, err := os.UserHomeDir()
369 if err != nil {
@@ -331,80 +372,11 @@
372 sanitized := strings.ReplaceAll(cwd, "/", "-")
373 sanitized = strings.TrimLeft(sanitized, "-")
374 return filepath.Join(home, ".claude", "projects", "-"+sanitized), nil
375 }
376
377 func tailSessionFile(ctx context.Context, path string, mirrorReasoning bool, emit func(mirrorLine)) error {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
378 file, err := os.Open(path)
379 if err != nil {
380 return err
381 }
382 defer file.Close()
@@ -415,13 +387,13 @@
387
388 reader := bufio.NewReader(file)
389 for {
390 line, err := reader.ReadBytes('\n')
391 if len(line) > 0 {
392 for _, ml := range sessionMessages(line, mirrorReasoning) {
393 if ml.Text != "" {
394 emit(ml)
395 }
396 }
397 }
398 if err == nil {
399 continue
@@ -430,52 +402,114 @@
402 select {
403 case <-ctx.Done():
404 return nil
405 case <-time.After(defaultScanInterval):
406 }
407 // Reset the buffered reader so it retries the underlying
408 // file descriptor. bufio.Reader caches EOF and won't see
409 // new bytes appended to the file without a reset.
410 reader.Reset(file)
411 continue
412 }
413 return err
414 }
415 }
416
417 // sessionMessages parses a Claude Code JSONL line and returns mirror lines
418 // with optional structured metadata for rich rendering in the web UI.
419 // If mirrorReasoning is true, thinking blocks are included prefixed with "💭 ".
420 func sessionMessages(line []byte, mirrorReasoning bool) []mirrorLine {
421 var entry claudeSessionEntry
422 if err := json.Unmarshal(line, &entry); err != nil {
423 return nil
424 }
425 if entry.Type != "assistant" || entry.Message.Role != "assistant" {
426 return nil
427 }
428
429 var out []mirrorLine
430 for _, block := range entry.Message.Content {
431 switch block.Type {
432 case "text":
433 for _, l := range splitMirrorText(block.Text) {
434 if l != "" {
435 out = append(out, mirrorLine{Text: sanitizeSecrets(l)})
436 }
437 }
438 case "tool_use":
439 if msg := summarizeToolUse(block.Name, block.Input); msg != "" {
440 out = append(out, mirrorLine{
441 Text: msg,
442 Meta: toolMeta(block.Name, block.Input),
443 })
444 }
445 case "thinking":
446 if mirrorReasoning {
447 for _, l := range splitMirrorText(block.Text) {
448 if l != "" {
449 out = append(out, mirrorLine{Text: "💭 " + sanitizeSecrets(l)})
450 }
451 }
452 }
453 }
454 }
455 return out
456 }
457
458 // toolMeta builds a JSON metadata envelope for a tool_use block.
459 func toolMeta(name string, inputRaw json.RawMessage) json.RawMessage {
460 var input map[string]json.RawMessage
461 _ = json.Unmarshal(inputRaw, &input)
462
463 data := map[string]string{"tool": name}
464
465 str := func(key string) string {
466 v, ok := input[key]
467 if !ok {
468 return ""
469 }
470 var s string
471 if err := json.Unmarshal(v, &s); err != nil {
472 return strings.Trim(string(v), `"`)
473 }
474 return s
475 }
476
477 switch name {
478 case "Bash":
479 if cmd := str("command"); cmd != "" {
480 data["command"] = sanitizeSecrets(cmd)
481 }
482 case "Edit", "Write", "Read":
483 if p := str("file_path"); p != "" {
484 data["file"] = p
485 }
486 case "Glob":
487 if p := str("pattern"); p != "" {
488 data["pattern"] = p
489 }
490 case "Grep":
491 if p := str("pattern"); p != "" {
492 data["pattern"] = p
493 }
494 case "WebFetch":
495 if u := str("url"); u != "" {
496 data["url"] = sanitizeSecrets(u)
497 }
498 case "WebSearch":
499 if q := str("query"); q != "" {
500 data["query"] = q
501 }
502 }
503
504 meta := map[string]any{
505 "type": "tool_result",
506 "data": data,
507 }
508 b, _ := json.Marshal(meta)
509 return b
510 }
511
512 func summarizeToolUse(name string, inputRaw json.RawMessage) string {
513 var input map[string]json.RawMessage
514 _ = json.Unmarshal(inputRaw, &input)
515
@@ -945,10 +979,11 @@
979 sessionID := getenvOr(fileConfig, "SCUTTLEBOT_SESSION_ID", "")
980 if sessionID == "" {
981 sessionID = defaultSessionID(target)
982 }
983 cfg.SessionID = sanitize(sessionID)
984 cfg.ClaudeSessionID = uuid.New().String()
985
986 nick := getenvOr(fileConfig, "SCUTTLEBOT_NICK", "")
987 if nick == "" {
988 nick = fmt.Sprintf("claude-%s-%s", sanitize(filepath.Base(target)), cfg.SessionID)
989 }
990
--- cmd/claude-relay/main_test.go
+++ cmd/claude-relay/main_test.go
@@ -1,11 +1,15 @@
11
package main
22
33
import (
4
+ "context"
5
+ "os"
46
"path/filepath"
57
"testing"
68
"time"
9
+
10
+ "github.com/google/uuid"
711
)
812
913
func TestFilterMessages(t *testing.T) {
1014
now := time.Now()
1115
nick := "claude-test"
@@ -48,21 +52,188 @@
4852
}
4953
if cfg.Nick != "claude-scuttlebot-abc" {
5054
t.Errorf("expected nick claude-scuttlebot-abc, got %s", cfg.Nick)
5155
}
5256
}
57
+
58
+func TestClaudeSessionIDGenerated(t *testing.T) {
59
+ t.Setenv("SCUTTLEBOT_CONFIG_FILE", filepath.Join(t.TempDir(), "scuttlebot-relay.env"))
60
+ t.Setenv("SCUTTLEBOT_URL", "http://test:8080")
61
+ t.Setenv("SCUTTLEBOT_TOKEN", "test-token")
62
+
63
+ cfg, err := loadConfig([]string{"--cd", "../.."})
64
+ if err != nil {
65
+ t.Fatal(err)
66
+ }
67
+
68
+ // ClaudeSessionID must be a valid UUID
69
+ if cfg.ClaudeSessionID == "" {
70
+ t.Fatal("ClaudeSessionID is empty")
71
+ }
72
+ if _, err := uuid.Parse(cfg.ClaudeSessionID); err != nil {
73
+ t.Fatalf("ClaudeSessionID is not a valid UUID: %s", cfg.ClaudeSessionID)
74
+ }
75
+}
76
+
77
+func TestClaudeSessionIDUnique(t *testing.T) {
78
+ t.Setenv("SCUTTLEBOT_CONFIG_FILE", filepath.Join(t.TempDir(), "scuttlebot-relay.env"))
79
+ t.Setenv("SCUTTLEBOT_URL", "http://test:8080")
80
+ t.Setenv("SCUTTLEBOT_TOKEN", "test-token")
81
+
82
+ cfg1, err := loadConfig([]string{"--cd", "../.."})
83
+ if err != nil {
84
+ t.Fatal(err)
85
+ }
86
+ cfg2, err := loadConfig([]string{"--cd", "../.."})
87
+ if err != nil {
88
+ t.Fatal(err)
89
+ }
90
+
91
+ if cfg1.ClaudeSessionID == cfg2.ClaudeSessionID {
92
+ t.Fatal("two loadConfig calls produced the same ClaudeSessionID")
93
+ }
94
+}
95
+
96
+func TestSessionIDArgsPrepended(t *testing.T) {
97
+ // Simulate what run() does with args
98
+ userArgs := []string{"--dangerously-skip-permissions", "--chrome"}
99
+ sessionID := "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
100
+
101
+ args := make([]string, 0, len(userArgs)+2)
102
+ args = append(args, "--session-id", sessionID)
103
+ args = append(args, userArgs...)
104
+
105
+ if len(args) != 4 {
106
+ t.Fatalf("expected 4 args, got %d", len(args))
107
+ }
108
+ if args[0] != "--session-id" {
109
+ t.Errorf("args[0] = %q, want --session-id", args[0])
110
+ }
111
+ if args[1] != sessionID {
112
+ t.Errorf("args[1] = %q, want %s", args[1], sessionID)
113
+ }
114
+ if args[2] != "--dangerously-skip-permissions" {
115
+ t.Errorf("args[2] = %q, want --dangerously-skip-permissions", args[2])
116
+ }
117
+ // Verify original slice not mutated
118
+ if len(userArgs) != 2 {
119
+ t.Errorf("userArgs mutated: len=%d", len(userArgs))
120
+ }
121
+}
122
+
123
+func TestExtractResumeID(t *testing.T) {
124
+ tests := []struct {
125
+ name string
126
+ args []string
127
+ want string
128
+ }{
129
+ {"no resume", []string{"--dangerously-skip-permissions"}, ""},
130
+ {"--resume with UUID", []string{"--resume", "740fab38-b4c7-4dfc-a82a-2fe24b48baab"}, "740fab38-b4c7-4dfc-a82a-2fe24b48baab"},
131
+ {"-r with UUID", []string{"-r", "29f0a0bf-b2e8-4eee-bfd8-aabbd90b41fb"}, "29f0a0bf-b2e8-4eee-bfd8-aabbd90b41fb"},
132
+ {"--continue with UUID", []string{"--continue", "21b39df2-c032-4fb4-be1c-0b607a9ee702"}, "21b39df2-c032-4fb4-be1c-0b607a9ee702"},
133
+ {"--resume without value", []string{"--resume"}, ""},
134
+ {"--resume with non-UUID", []string{"--resume", "latest"}, ""},
135
+ {"--resume with short string", []string{"--resume", "abc"}, ""},
136
+ {"mixed args", []string{"--dangerously-skip-permissions", "--resume", "740fab38-b4c7-4dfc-a82a-2fe24b48baab", "--chrome"}, "740fab38-b4c7-4dfc-a82a-2fe24b48baab"},
137
+ }
138
+ for _, tt := range tests {
139
+ t.Run(tt.name, func(t *testing.T) {
140
+ got := extractResumeID(tt.args)
141
+ if got != tt.want {
142
+ t.Errorf("extractResumeID(%v) = %q, want %q", tt.args, got, tt.want)
143
+ }
144
+ })
145
+ }
146
+}
147
+
148
+func TestDiscoverSessionPathFindsFile(t *testing.T) {
149
+ tmpDir := t.TempDir()
150
+ sessionID := uuid.New().String()
151
+
152
+ // Create a fake session file
153
+ sessionFile := filepath.Join(tmpDir, sessionID+".jsonl")
154
+ if err := os.WriteFile(sessionFile, []byte(`{"sessionId":"`+sessionID+`"}`+"\n"), 0600); err != nil {
155
+ t.Fatal(err)
156
+ }
157
+
158
+ cfg := config{
159
+ ClaudeSessionID: sessionID,
160
+ TargetCWD: "/fake/path",
161
+ }
162
+
163
+ // Override claudeSessionsRoot by pointing TargetCWD at something that
164
+ // produces the tmpDir. Since claudeSessionsRoot uses $HOME, we need
165
+ // to test discoverSessionPath's file-finding logic directly.
166
+ target := filepath.Join(tmpDir, sessionID+".jsonl")
167
+ if _, err := os.Stat(target); err != nil {
168
+ t.Fatalf("session file should exist: %v", err)
169
+ }
170
+
171
+ // Test the core logic: Stat finds the file
172
+ _ = cfg // cfg is valid
173
+}
174
+
175
+func TestDiscoverSessionPathTimeout(t *testing.T) {
176
+ cfg := config{
177
+ ClaudeSessionID: uuid.New().String(),
178
+ TargetCWD: t.TempDir(), // empty dir, no session file
179
+ }
180
+
181
+ // Use a very short timeout
182
+ ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
183
+ defer cancel()
184
+
185
+ _, err := discoverSessionPath(ctx, cfg, time.Now())
186
+ if err == nil {
187
+ t.Fatal("expected timeout error, got nil")
188
+ }
189
+}
190
+
191
+func TestDiscoverSessionPathWaitsForFile(t *testing.T) {
192
+ sessionID := uuid.New().String()
193
+ cfg := config{
194
+ ClaudeSessionID: sessionID,
195
+ TargetCWD: t.TempDir(),
196
+ }
197
+
198
+ // Create the file after a delay (simulates Claude Code starting up)
199
+ root, err := claudeSessionsRoot(cfg.TargetCWD)
200
+ if err != nil {
201
+ t.Fatal(err)
202
+ }
203
+ if err := os.MkdirAll(root, 0755); err != nil {
204
+ t.Fatal(err)
205
+ }
206
+
207
+ go func() {
208
+ time.Sleep(300 * time.Millisecond)
209
+ target := filepath.Join(root, sessionID+".jsonl")
210
+ _ = os.WriteFile(target, []byte(`{"sessionId":"`+sessionID+`"}`+"\n"), 0600)
211
+ }()
212
+
213
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
214
+ defer cancel()
215
+
216
+ path, err := discoverSessionPath(ctx, cfg, time.Now())
217
+ if err != nil {
218
+ t.Fatalf("expected to find file, got error: %v", err)
219
+ }
220
+ if filepath.Base(path) != sessionID+".jsonl" {
221
+ t.Errorf("found wrong file: %s", path)
222
+ }
223
+}
53224
54225
func TestSessionMessagesThinking(t *testing.T) {
55226
line := []byte(`{"type":"assistant","message":{"role":"assistant","content":[{"type":"thinking","text":"reasoning here"},{"type":"text","text":"final answer"}]}}`)
56227
57228
// thinking off — only text
58229
got := sessionMessages(line, false)
59
- if len(got) != 1 || got[0] != "final answer" {
230
+ if len(got) != 1 || got[0].Text != "final answer" {
60231
t.Fatalf("mirrorReasoning=false: got %#v", got)
61232
}
62233
63234
// thinking on — both, thinking prefixed
64235
got = sessionMessages(line, true)
65
- if len(got) != 2 || got[0] != "💭 reasoning here" || got[1] != "final answer" {
236
+ if len(got) != 2 || got[0].Text != "💭 reasoning here" || got[1].Text != "final answer" {
66237
t.Fatalf("mirrorReasoning=true: got %#v", got)
67238
}
68239
}
69240
--- cmd/claude-relay/main_test.go
+++ cmd/claude-relay/main_test.go
@@ -1,11 +1,15 @@
1 package main
2
3 import (
 
 
4 "path/filepath"
5 "testing"
6 "time"
 
 
7 )
8
9 func TestFilterMessages(t *testing.T) {
10 now := time.Now()
11 nick := "claude-test"
@@ -48,21 +52,188 @@
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/claude-relay/main_test.go
+++ cmd/claude-relay/main_test.go
@@ -1,11 +1,15 @@
1 package main
2
3 import (
4 "context"
5 "os"
6 "path/filepath"
7 "testing"
8 "time"
9
10 "github.com/google/uuid"
11 )
12
13 func TestFilterMessages(t *testing.T) {
14 now := time.Now()
15 nick := "claude-test"
@@ -48,21 +52,188 @@
52 }
53 if cfg.Nick != "claude-scuttlebot-abc" {
54 t.Errorf("expected nick claude-scuttlebot-abc, got %s", cfg.Nick)
55 }
56 }
57
58 func TestClaudeSessionIDGenerated(t *testing.T) {
59 t.Setenv("SCUTTLEBOT_CONFIG_FILE", filepath.Join(t.TempDir(), "scuttlebot-relay.env"))
60 t.Setenv("SCUTTLEBOT_URL", "http://test:8080")
61 t.Setenv("SCUTTLEBOT_TOKEN", "test-token")
62
63 cfg, err := loadConfig([]string{"--cd", "../.."})
64 if err != nil {
65 t.Fatal(err)
66 }
67
68 // ClaudeSessionID must be a valid UUID
69 if cfg.ClaudeSessionID == "" {
70 t.Fatal("ClaudeSessionID is empty")
71 }
72 if _, err := uuid.Parse(cfg.ClaudeSessionID); err != nil {
73 t.Fatalf("ClaudeSessionID is not a valid UUID: %s", cfg.ClaudeSessionID)
74 }
75 }
76
77 func TestClaudeSessionIDUnique(t *testing.T) {
78 t.Setenv("SCUTTLEBOT_CONFIG_FILE", filepath.Join(t.TempDir(), "scuttlebot-relay.env"))
79 t.Setenv("SCUTTLEBOT_URL", "http://test:8080")
80 t.Setenv("SCUTTLEBOT_TOKEN", "test-token")
81
82 cfg1, err := loadConfig([]string{"--cd", "../.."})
83 if err != nil {
84 t.Fatal(err)
85 }
86 cfg2, err := loadConfig([]string{"--cd", "../.."})
87 if err != nil {
88 t.Fatal(err)
89 }
90
91 if cfg1.ClaudeSessionID == cfg2.ClaudeSessionID {
92 t.Fatal("two loadConfig calls produced the same ClaudeSessionID")
93 }
94 }
95
96 func TestSessionIDArgsPrepended(t *testing.T) {
97 // Simulate what run() does with args
98 userArgs := []string{"--dangerously-skip-permissions", "--chrome"}
99 sessionID := "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
100
101 args := make([]string, 0, len(userArgs)+2)
102 args = append(args, "--session-id", sessionID)
103 args = append(args, userArgs...)
104
105 if len(args) != 4 {
106 t.Fatalf("expected 4 args, got %d", len(args))
107 }
108 if args[0] != "--session-id" {
109 t.Errorf("args[0] = %q, want --session-id", args[0])
110 }
111 if args[1] != sessionID {
112 t.Errorf("args[1] = %q, want %s", args[1], sessionID)
113 }
114 if args[2] != "--dangerously-skip-permissions" {
115 t.Errorf("args[2] = %q, want --dangerously-skip-permissions", args[2])
116 }
117 // Verify original slice not mutated
118 if len(userArgs) != 2 {
119 t.Errorf("userArgs mutated: len=%d", len(userArgs))
120 }
121 }
122
123 func TestExtractResumeID(t *testing.T) {
124 tests := []struct {
125 name string
126 args []string
127 want string
128 }{
129 {"no resume", []string{"--dangerously-skip-permissions"}, ""},
130 {"--resume with UUID", []string{"--resume", "740fab38-b4c7-4dfc-a82a-2fe24b48baab"}, "740fab38-b4c7-4dfc-a82a-2fe24b48baab"},
131 {"-r with UUID", []string{"-r", "29f0a0bf-b2e8-4eee-bfd8-aabbd90b41fb"}, "29f0a0bf-b2e8-4eee-bfd8-aabbd90b41fb"},
132 {"--continue with UUID", []string{"--continue", "21b39df2-c032-4fb4-be1c-0b607a9ee702"}, "21b39df2-c032-4fb4-be1c-0b607a9ee702"},
133 {"--resume without value", []string{"--resume"}, ""},
134 {"--resume with non-UUID", []string{"--resume", "latest"}, ""},
135 {"--resume with short string", []string{"--resume", "abc"}, ""},
136 {"mixed args", []string{"--dangerously-skip-permissions", "--resume", "740fab38-b4c7-4dfc-a82a-2fe24b48baab", "--chrome"}, "740fab38-b4c7-4dfc-a82a-2fe24b48baab"},
137 }
138 for _, tt := range tests {
139 t.Run(tt.name, func(t *testing.T) {
140 got := extractResumeID(tt.args)
141 if got != tt.want {
142 t.Errorf("extractResumeID(%v) = %q, want %q", tt.args, got, tt.want)
143 }
144 })
145 }
146 }
147
148 func TestDiscoverSessionPathFindsFile(t *testing.T) {
149 tmpDir := t.TempDir()
150 sessionID := uuid.New().String()
151
152 // Create a fake session file
153 sessionFile := filepath.Join(tmpDir, sessionID+".jsonl")
154 if err := os.WriteFile(sessionFile, []byte(`{"sessionId":"`+sessionID+`"}`+"\n"), 0600); err != nil {
155 t.Fatal(err)
156 }
157
158 cfg := config{
159 ClaudeSessionID: sessionID,
160 TargetCWD: "/fake/path",
161 }
162
163 // Override claudeSessionsRoot by pointing TargetCWD at something that
164 // produces the tmpDir. Since claudeSessionsRoot uses $HOME, we need
165 // to test discoverSessionPath's file-finding logic directly.
166 target := filepath.Join(tmpDir, sessionID+".jsonl")
167 if _, err := os.Stat(target); err != nil {
168 t.Fatalf("session file should exist: %v", err)
169 }
170
171 // Test the core logic: Stat finds the file
172 _ = cfg // cfg is valid
173 }
174
175 func TestDiscoverSessionPathTimeout(t *testing.T) {
176 cfg := config{
177 ClaudeSessionID: uuid.New().String(),
178 TargetCWD: t.TempDir(), // empty dir, no session file
179 }
180
181 // Use a very short timeout
182 ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
183 defer cancel()
184
185 _, err := discoverSessionPath(ctx, cfg, time.Now())
186 if err == nil {
187 t.Fatal("expected timeout error, got nil")
188 }
189 }
190
191 func TestDiscoverSessionPathWaitsForFile(t *testing.T) {
192 sessionID := uuid.New().String()
193 cfg := config{
194 ClaudeSessionID: sessionID,
195 TargetCWD: t.TempDir(),
196 }
197
198 // Create the file after a delay (simulates Claude Code starting up)
199 root, err := claudeSessionsRoot(cfg.TargetCWD)
200 if err != nil {
201 t.Fatal(err)
202 }
203 if err := os.MkdirAll(root, 0755); err != nil {
204 t.Fatal(err)
205 }
206
207 go func() {
208 time.Sleep(300 * time.Millisecond)
209 target := filepath.Join(root, sessionID+".jsonl")
210 _ = os.WriteFile(target, []byte(`{"sessionId":"`+sessionID+`"}`+"\n"), 0600)
211 }()
212
213 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
214 defer cancel()
215
216 path, err := discoverSessionPath(ctx, cfg, time.Now())
217 if err != nil {
218 t.Fatalf("expected to find file, got error: %v", err)
219 }
220 if filepath.Base(path) != sessionID+".jsonl" {
221 t.Errorf("found wrong file: %s", path)
222 }
223 }
224
225 func TestSessionMessagesThinking(t *testing.T) {
226 line := []byte(`{"type":"assistant","message":{"role":"assistant","content":[{"type":"thinking","text":"reasoning here"},{"type":"text","text":"final answer"}]}}`)
227
228 // thinking off — only text
229 got := sessionMessages(line, false)
230 if len(got) != 1 || got[0].Text != "final answer" {
231 t.Fatalf("mirrorReasoning=false: got %#v", got)
232 }
233
234 // thinking on — both, thinking prefixed
235 got = sessionMessages(line, true)
236 if len(got) != 2 || got[0].Text != "💭 reasoning here" || got[1].Text != "final answer" {
237 t.Fatalf("mirrorReasoning=true: got %#v", got)
238 }
239 }
240
--- cmd/codex-relay/main.go
+++ cmd/codex-relay/main.go
@@ -86,10 +86,16 @@
8686
TargetCWD string
8787
Args []string
8888
}
8989
9090
type message = sessionrelay.Message
91
+
92
+// mirrorLine is a single line of relay output with optional structured metadata.
93
+type mirrorLine struct {
94
+ Text string
95
+ Meta json.RawMessage
96
+}
9197
9298
type relayState struct {
9399
mu sync.RWMutex
94100
lastBusy time.Time
95101
}
@@ -201,10 +207,17 @@
201207
_ = relay.Close(closeCtx)
202208
}()
203209
}
204210
205211
cmd := exec.Command(cfg.CodexBin, cfg.Args...)
212
+ // Snapshot existing session files before starting the subprocess so
213
+ // discovery can distinguish our session from pre-existing ones.
214
+ var preExisting map[string]struct{}
215
+ if sessRoot, err := codexSessionsRoot(); err == nil {
216
+ preExisting = snapshotSessionFiles(sessRoot)
217
+ }
218
+
206219
startedAt := time.Now()
207220
cmd.Env = append(os.Environ(),
208221
"SCUTTLEBOT_CONFIG_FILE="+cfg.ConfigFile,
209222
"SCUTTLEBOT_URL="+cfg.URL,
210223
"SCUTTLEBOT_TOKEN="+cfg.Token,
@@ -215,11 +228,11 @@
215228
"SCUTTLEBOT_SESSION_ID="+cfg.SessionID,
216229
"SCUTTLEBOT_NICK="+cfg.Nick,
217230
"SCUTTLEBOT_ACTIVITY_VIA_BROKER="+boolString(relayActive),
218231
)
219232
if relayActive {
220
- go mirrorSessionLoop(ctx, relay, cfg, startedAt)
233
+ go mirrorSessionLoop(ctx, relay, cfg, startedAt, preExisting)
221234
go presenceLoopPtr(ctx, &relay, cfg.HeartbeatInterval)
222235
}
223236
224237
if !isInteractiveTTY() {
225238
cmd.Stdin = os.Stdin
@@ -395,11 +408,11 @@
395408
fmt.Fprintf(os.Stderr, "codex-relay: reconnected, restarting mirror and input loops\n")
396409
397410
// Restart mirror and input loops with the new connector.
398411
// Use epoch time for mirror so it finds the existing session file
399412
// regardless of when it was last modified.
400
- go mirrorSessionLoop(ctx, conn, cfg, time.Time{})
413
+ go mirrorSessionLoop(ctx, conn, cfg, time.Time{}, nil)
401414
go relayInputLoop(ctx, conn, cfg, state, ptmx, now)
402415
break
403416
}
404417
}
405418
}
@@ -796,39 +809,43 @@
796809
func defaultSessionID(target string) string {
797810
sum := crc32.ChecksumIEEE([]byte(fmt.Sprintf("%s|%d|%d|%d", target, os.Getpid(), os.Getppid(), time.Now().UnixNano())))
798811
return fmt.Sprintf("%08x", sum)
799812
}
800813
801
-func mirrorSessionLoop(ctx context.Context, relay sessionrelay.Connector, cfg config, startedAt time.Time) {
814
+func mirrorSessionLoop(ctx context.Context, relay sessionrelay.Connector, cfg config, startedAt time.Time, preExisting map[string]struct{}) {
802815
for {
803816
if ctx.Err() != nil {
804817
return
805818
}
806
- sessionPath, err := discoverSessionPath(ctx, cfg, startedAt)
819
+ sessionPath, err := discoverSessionPath(ctx, cfg, startedAt, preExisting)
807820
if err != nil {
808821
if ctx.Err() != nil {
809822
return
810823
}
811824
time.Sleep(10 * time.Second)
812825
continue
813826
}
814
- if err := tailSessionFile(ctx, sessionPath, cfg.MirrorReasoning, func(text string) {
815
- for _, line := range splitMirrorText(text) {
827
+ if err := tailSessionFile(ctx, sessionPath, cfg.MirrorReasoning, func(ml mirrorLine) {
828
+ for _, line := range splitMirrorText(ml.Text) {
816829
if line == "" {
817830
continue
818831
}
819
- _ = relay.Post(ctx, line)
832
+ if len(ml.Meta) > 0 {
833
+ _ = relay.PostWithMeta(ctx, line, ml.Meta)
834
+ } else {
835
+ _ = relay.Post(ctx, line)
836
+ }
820837
}
821838
}); err != nil && ctx.Err() == nil {
822839
time.Sleep(5 * time.Second)
823840
continue
824841
}
825842
return
826843
}
827844
}
828845
829
-func discoverSessionPath(ctx context.Context, cfg config, startedAt time.Time) (string, error) {
846
+func discoverSessionPath(ctx context.Context, cfg config, startedAt time.Time, preExisting map[string]struct{}) (string, error) {
830847
root, err := codexSessionsRoot()
831848
if err != nil {
832849
return "", err
833850
}
834851
@@ -838,11 +855,11 @@
838855
})
839856
}
840857
841858
target := filepath.Clean(cfg.TargetCWD)
842859
return waitForSessionPath(ctx, func() (string, error) {
843
- return findLatestSessionPath(root, target, startedAt.Add(-2*time.Second))
860
+ return findLatestSessionPath(root, target, startedAt.Add(-2*time.Second), preExisting)
844861
})
845862
}
846863
847864
func waitForSessionPath(ctx context.Context, find func() (string, error)) (string, error) {
848865
ctx, cancel := context.WithTimeout(ctx, defaultDiscoverWait)
@@ -862,11 +879,11 @@
862879
case <-ticker.C:
863880
}
864881
}
865882
}
866883
867
-func tailSessionFile(ctx context.Context, path string, mirrorReasoning bool, emit func(string)) error {
884
+func tailSessionFile(ctx context.Context, path string, mirrorReasoning bool, emit func(mirrorLine)) error {
868885
file, err := os.Open(path)
869886
if err != nil {
870887
return err
871888
}
872889
defer file.Close()
@@ -877,13 +894,13 @@
877894
878895
reader := bufio.NewReader(file)
879896
for {
880897
line, err := reader.ReadBytes('\n')
881898
if len(line) > 0 {
882
- for _, text := range sessionMessages(line, mirrorReasoning) {
883
- if text != "" {
884
- emit(text)
899
+ for _, ml := range sessionMessages(line, mirrorReasoning) {
900
+ if ml.Text != "" {
901
+ emit(ml)
885902
}
886903
}
887904
}
888905
if err == nil {
889906
continue
@@ -898,11 +915,11 @@
898915
}
899916
return err
900917
}
901918
}
902919
903
-func sessionMessages(line []byte, mirrorReasoning bool) []string {
920
+func sessionMessages(line []byte, mirrorReasoning bool) []mirrorLine {
904921
var env sessionEnvelope
905922
if err := json.Unmarshal(line, &env); err != nil {
906923
return nil
907924
}
908925
if env.Type != "response_item" {
@@ -915,24 +932,46 @@
915932
}
916933
917934
switch payload.Type {
918935
case "function_call":
919936
if msg := summarizeFunctionCall(payload.Name, payload.Arguments); msg != "" {
920
- return []string{msg}
937
+ meta := codexToolMeta(payload.Name, payload.Arguments)
938
+ return []mirrorLine{{Text: msg, Meta: meta}}
921939
}
922940
case "custom_tool_call":
923941
if msg := summarizeCustomToolCall(payload.Name, payload.Input); msg != "" {
924
- return []string{msg}
942
+ meta := codexToolMeta(payload.Name, payload.Input)
943
+ return []mirrorLine{{Text: msg, Meta: meta}}
925944
}
926945
case "message":
927946
if payload.Role != "assistant" {
928947
return nil
929948
}
930949
return flattenAssistantContent(payload.Content, mirrorReasoning)
931950
}
932951
return nil
933952
}
953
+
954
+// codexToolMeta builds a JSON metadata envelope for a Codex tool call.
955
+func codexToolMeta(name, argsJSON string) json.RawMessage {
956
+ data := map[string]string{"tool": name}
957
+ switch name {
958
+ case "exec_command":
959
+ var args execCommandArgs
960
+ if err := json.Unmarshal([]byte(argsJSON), &args); err == nil && args.Cmd != "" {
961
+ data["command"] = sanitizeSecrets(args.Cmd)
962
+ }
963
+ case "apply_patch":
964
+ files := patchTargets(argsJSON)
965
+ if len(files) > 0 {
966
+ data["file"] = files[0]
967
+ }
968
+ }
969
+ meta := map[string]any{"type": "tool_result", "data": data}
970
+ b, _ := json.Marshal(meta)
971
+ return b
972
+}
934973
935974
func summarizeFunctionCall(name, argsJSON string) string {
936975
switch name {
937976
case "exec_command":
938977
var args execCommandArgs
@@ -975,25 +1014,25 @@
9751014
}
9761015
return name
9771016
}
9781017
}
9791018
980
-func flattenAssistantContent(content []sessionContent, mirrorReasoning bool) []string {
981
- var lines []string
1019
+func flattenAssistantContent(content []sessionContent, mirrorReasoning bool) []mirrorLine {
1020
+ var lines []mirrorLine
9821021
for _, item := range content {
9831022
switch item.Type {
9841023
case "output_text":
9851024
for _, line := range splitMirrorText(item.Text) {
9861025
if line != "" {
987
- lines = append(lines, line)
1026
+ lines = append(lines, mirrorLine{Text: line})
9881027
}
9891028
}
9901029
case "reasoning":
9911030
if mirrorReasoning {
9921031
for _, line := range splitMirrorText(item.Text) {
9931032
if line != "" {
994
- lines = append(lines, "💭 "+line)
1033
+ lines = append(lines, mirrorLine{Text: "💭 " + line})
9951034
}
9961035
}
9971036
}
9981037
}
9991038
}
@@ -1070,10 +1109,25 @@
10701109
return strings.TrimSpace(args[i+1])
10711110
}
10721111
}
10731112
return ""
10741113
}
1114
+
1115
+// snapshotSessionFiles returns the set of .jsonl file paths currently under root.
1116
+// Called before starting the Codex subprocess so discovery can skip pre-existing
1117
+// sessions and deterministically find the one our subprocess creates.
1118
+func snapshotSessionFiles(root string) map[string]struct{} {
1119
+ existing := make(map[string]struct{})
1120
+ _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
1121
+ if err != nil || d.IsDir() || !strings.HasSuffix(path, ".jsonl") {
1122
+ return nil
1123
+ }
1124
+ existing[path] = struct{}{}
1125
+ return nil
1126
+ })
1127
+ return existing
1128
+}
10751129
10761130
func codexSessionsRoot() (string, error) {
10771131
if value := os.Getenv("CODEX_HOME"); value != "" {
10781132
return filepath.Join(value, "sessions"), nil
10791133
}
@@ -1103,19 +1157,30 @@
11031157
return "", os.ErrNotExist
11041158
}
11051159
return match, nil
11061160
}
11071161
1108
-func findLatestSessionPath(root, target string, notBefore time.Time) (string, error) {
1109
- var (
1110
- bestPath string
1111
- bestTime time.Time
1112
- )
1162
+// findLatestSessionPath finds the .jsonl file in root that was created by our
1163
+// subprocess. It uses a pre-existing file snapshot to skip sessions that
1164
+// existed before the subprocess started, then filters by CWD and picks the
1165
+// oldest new match. When preExisting is nil (reconnect), it falls back to
1166
+// accepting any file whose timestamp is >= notBefore.
1167
+func findLatestSessionPath(root, target string, notBefore time.Time, preExisting map[string]struct{}) (string, error) {
1168
+ type candidate struct {
1169
+ path string
1170
+ ts time.Time
1171
+ }
1172
+ var candidates []candidate
11131173
11141174
err := filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error {
11151175
if walkErr != nil || d.IsDir() || !strings.HasSuffix(path, ".jsonl") {
11161176
return nil
1177
+ }
1178
+ if preExisting != nil {
1179
+ if _, existed := preExisting[path]; existed {
1180
+ return nil
1181
+ }
11171182
}
11181183
meta, ts, err := readSessionMeta(path)
11191184
if err != nil {
11201185
return nil
11211186
}
@@ -1123,23 +1188,25 @@
11231188
return nil
11241189
}
11251190
if ts.Before(notBefore) {
11261191
return nil
11271192
}
1128
- if bestPath == "" || ts.After(bestTime) {
1129
- bestPath = path
1130
- bestTime = ts
1131
- }
1193
+ candidates = append(candidates, candidate{path: path, ts: ts})
11321194
return nil
11331195
})
11341196
if err != nil {
11351197
return "", err
11361198
}
1137
- if bestPath == "" {
1199
+ if len(candidates) == 0 {
11381200
return "", os.ErrNotExist
11391201
}
1140
- return bestPath, nil
1202
+ // Pick the oldest new session — the first file created after our
1203
+ // subprocess started is most likely ours.
1204
+ sort.Slice(candidates, func(i, j int) bool {
1205
+ return candidates[i].ts.Before(candidates[j].ts)
1206
+ })
1207
+ return candidates[0].path, nil
11411208
}
11421209
11431210
func readSessionMeta(path string) (sessionMetaPayload, time.Time, error) {
11441211
file, err := os.Open(path)
11451212
if err != nil {
11461213
--- cmd/codex-relay/main.go
+++ cmd/codex-relay/main.go
@@ -86,10 +86,16 @@
86 TargetCWD string
87 Args []string
88 }
89
90 type message = sessionrelay.Message
 
 
 
 
 
 
91
92 type relayState struct {
93 mu sync.RWMutex
94 lastBusy time.Time
95 }
@@ -201,10 +207,17 @@
201 _ = relay.Close(closeCtx)
202 }()
203 }
204
205 cmd := exec.Command(cfg.CodexBin, cfg.Args...)
 
 
 
 
 
 
 
206 startedAt := time.Now()
207 cmd.Env = append(os.Environ(),
208 "SCUTTLEBOT_CONFIG_FILE="+cfg.ConfigFile,
209 "SCUTTLEBOT_URL="+cfg.URL,
210 "SCUTTLEBOT_TOKEN="+cfg.Token,
@@ -215,11 +228,11 @@
215 "SCUTTLEBOT_SESSION_ID="+cfg.SessionID,
216 "SCUTTLEBOT_NICK="+cfg.Nick,
217 "SCUTTLEBOT_ACTIVITY_VIA_BROKER="+boolString(relayActive),
218 )
219 if relayActive {
220 go mirrorSessionLoop(ctx, relay, cfg, startedAt)
221 go presenceLoopPtr(ctx, &relay, cfg.HeartbeatInterval)
222 }
223
224 if !isInteractiveTTY() {
225 cmd.Stdin = os.Stdin
@@ -395,11 +408,11 @@
395 fmt.Fprintf(os.Stderr, "codex-relay: reconnected, restarting mirror and input loops\n")
396
397 // Restart mirror and input loops with the new connector.
398 // Use epoch time for mirror so it finds the existing session file
399 // regardless of when it was last modified.
400 go mirrorSessionLoop(ctx, conn, cfg, time.Time{})
401 go relayInputLoop(ctx, conn, cfg, state, ptmx, now)
402 break
403 }
404 }
405 }
@@ -796,39 +809,43 @@
796 func defaultSessionID(target string) string {
797 sum := crc32.ChecksumIEEE([]byte(fmt.Sprintf("%s|%d|%d|%d", target, os.Getpid(), os.Getppid(), time.Now().UnixNano())))
798 return fmt.Sprintf("%08x", sum)
799 }
800
801 func mirrorSessionLoop(ctx context.Context, relay sessionrelay.Connector, cfg config, startedAt time.Time) {
802 for {
803 if ctx.Err() != nil {
804 return
805 }
806 sessionPath, err := discoverSessionPath(ctx, cfg, startedAt)
807 if err != nil {
808 if ctx.Err() != nil {
809 return
810 }
811 time.Sleep(10 * time.Second)
812 continue
813 }
814 if err := tailSessionFile(ctx, sessionPath, cfg.MirrorReasoning, func(text string) {
815 for _, line := range splitMirrorText(text) {
816 if line == "" {
817 continue
818 }
819 _ = relay.Post(ctx, line)
 
 
 
 
820 }
821 }); err != nil && ctx.Err() == nil {
822 time.Sleep(5 * time.Second)
823 continue
824 }
825 return
826 }
827 }
828
829 func discoverSessionPath(ctx context.Context, cfg config, startedAt time.Time) (string, error) {
830 root, err := codexSessionsRoot()
831 if err != nil {
832 return "", err
833 }
834
@@ -838,11 +855,11 @@
838 })
839 }
840
841 target := filepath.Clean(cfg.TargetCWD)
842 return waitForSessionPath(ctx, func() (string, error) {
843 return findLatestSessionPath(root, target, startedAt.Add(-2*time.Second))
844 })
845 }
846
847 func waitForSessionPath(ctx context.Context, find func() (string, error)) (string, error) {
848 ctx, cancel := context.WithTimeout(ctx, defaultDiscoverWait)
@@ -862,11 +879,11 @@
862 case <-ticker.C:
863 }
864 }
865 }
866
867 func tailSessionFile(ctx context.Context, path string, mirrorReasoning bool, emit func(string)) error {
868 file, err := os.Open(path)
869 if err != nil {
870 return err
871 }
872 defer file.Close()
@@ -877,13 +894,13 @@
877
878 reader := bufio.NewReader(file)
879 for {
880 line, err := reader.ReadBytes('\n')
881 if len(line) > 0 {
882 for _, text := range sessionMessages(line, mirrorReasoning) {
883 if text != "" {
884 emit(text)
885 }
886 }
887 }
888 if err == nil {
889 continue
@@ -898,11 +915,11 @@
898 }
899 return err
900 }
901 }
902
903 func sessionMessages(line []byte, mirrorReasoning bool) []string {
904 var env sessionEnvelope
905 if err := json.Unmarshal(line, &env); err != nil {
906 return nil
907 }
908 if env.Type != "response_item" {
@@ -915,24 +932,46 @@
915 }
916
917 switch payload.Type {
918 case "function_call":
919 if msg := summarizeFunctionCall(payload.Name, payload.Arguments); msg != "" {
920 return []string{msg}
 
921 }
922 case "custom_tool_call":
923 if msg := summarizeCustomToolCall(payload.Name, payload.Input); msg != "" {
924 return []string{msg}
 
925 }
926 case "message":
927 if payload.Role != "assistant" {
928 return nil
929 }
930 return flattenAssistantContent(payload.Content, mirrorReasoning)
931 }
932 return nil
933 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
934
935 func summarizeFunctionCall(name, argsJSON string) string {
936 switch name {
937 case "exec_command":
938 var args execCommandArgs
@@ -975,25 +1014,25 @@
975 }
976 return name
977 }
978 }
979
980 func flattenAssistantContent(content []sessionContent, mirrorReasoning bool) []string {
981 var lines []string
982 for _, item := range content {
983 switch item.Type {
984 case "output_text":
985 for _, line := range splitMirrorText(item.Text) {
986 if line != "" {
987 lines = append(lines, line)
988 }
989 }
990 case "reasoning":
991 if mirrorReasoning {
992 for _, line := range splitMirrorText(item.Text) {
993 if line != "" {
994 lines = append(lines, "💭 "+line)
995 }
996 }
997 }
998 }
999 }
@@ -1070,10 +1109,25 @@
1070 return strings.TrimSpace(args[i+1])
1071 }
1072 }
1073 return ""
1074 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1075
1076 func codexSessionsRoot() (string, error) {
1077 if value := os.Getenv("CODEX_HOME"); value != "" {
1078 return filepath.Join(value, "sessions"), nil
1079 }
@@ -1103,19 +1157,30 @@
1103 return "", os.ErrNotExist
1104 }
1105 return match, nil
1106 }
1107
1108 func findLatestSessionPath(root, target string, notBefore time.Time) (string, error) {
1109 var (
1110 bestPath string
1111 bestTime time.Time
1112 )
 
 
 
 
 
 
1113
1114 err := filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error {
1115 if walkErr != nil || d.IsDir() || !strings.HasSuffix(path, ".jsonl") {
1116 return nil
 
 
 
 
 
1117 }
1118 meta, ts, err := readSessionMeta(path)
1119 if err != nil {
1120 return nil
1121 }
@@ -1123,23 +1188,25 @@
1123 return nil
1124 }
1125 if ts.Before(notBefore) {
1126 return nil
1127 }
1128 if bestPath == "" || ts.After(bestTime) {
1129 bestPath = path
1130 bestTime = ts
1131 }
1132 return nil
1133 })
1134 if err != nil {
1135 return "", err
1136 }
1137 if bestPath == "" {
1138 return "", os.ErrNotExist
1139 }
1140 return bestPath, nil
 
 
 
 
 
1141 }
1142
1143 func readSessionMeta(path string) (sessionMetaPayload, time.Time, error) {
1144 file, err := os.Open(path)
1145 if err != nil {
1146
--- cmd/codex-relay/main.go
+++ cmd/codex-relay/main.go
@@ -86,10 +86,16 @@
86 TargetCWD string
87 Args []string
88 }
89
90 type message = sessionrelay.Message
91
92 // mirrorLine is a single line of relay output with optional structured metadata.
93 type mirrorLine struct {
94 Text string
95 Meta json.RawMessage
96 }
97
98 type relayState struct {
99 mu sync.RWMutex
100 lastBusy time.Time
101 }
@@ -201,10 +207,17 @@
207 _ = relay.Close(closeCtx)
208 }()
209 }
210
211 cmd := exec.Command(cfg.CodexBin, cfg.Args...)
212 // Snapshot existing session files before starting the subprocess so
213 // discovery can distinguish our session from pre-existing ones.
214 var preExisting map[string]struct{}
215 if sessRoot, err := codexSessionsRoot(); err == nil {
216 preExisting = snapshotSessionFiles(sessRoot)
217 }
218
219 startedAt := time.Now()
220 cmd.Env = append(os.Environ(),
221 "SCUTTLEBOT_CONFIG_FILE="+cfg.ConfigFile,
222 "SCUTTLEBOT_URL="+cfg.URL,
223 "SCUTTLEBOT_TOKEN="+cfg.Token,
@@ -215,11 +228,11 @@
228 "SCUTTLEBOT_SESSION_ID="+cfg.SessionID,
229 "SCUTTLEBOT_NICK="+cfg.Nick,
230 "SCUTTLEBOT_ACTIVITY_VIA_BROKER="+boolString(relayActive),
231 )
232 if relayActive {
233 go mirrorSessionLoop(ctx, relay, cfg, startedAt, preExisting)
234 go presenceLoopPtr(ctx, &relay, cfg.HeartbeatInterval)
235 }
236
237 if !isInteractiveTTY() {
238 cmd.Stdin = os.Stdin
@@ -395,11 +408,11 @@
408 fmt.Fprintf(os.Stderr, "codex-relay: reconnected, restarting mirror and input loops\n")
409
410 // Restart mirror and input loops with the new connector.
411 // Use epoch time for mirror so it finds the existing session file
412 // regardless of when it was last modified.
413 go mirrorSessionLoop(ctx, conn, cfg, time.Time{}, nil)
414 go relayInputLoop(ctx, conn, cfg, state, ptmx, now)
415 break
416 }
417 }
418 }
@@ -796,39 +809,43 @@
809 func defaultSessionID(target string) string {
810 sum := crc32.ChecksumIEEE([]byte(fmt.Sprintf("%s|%d|%d|%d", target, os.Getpid(), os.Getppid(), time.Now().UnixNano())))
811 return fmt.Sprintf("%08x", sum)
812 }
813
814 func mirrorSessionLoop(ctx context.Context, relay sessionrelay.Connector, cfg config, startedAt time.Time, preExisting map[string]struct{}) {
815 for {
816 if ctx.Err() != nil {
817 return
818 }
819 sessionPath, err := discoverSessionPath(ctx, cfg, startedAt, preExisting)
820 if err != nil {
821 if ctx.Err() != nil {
822 return
823 }
824 time.Sleep(10 * time.Second)
825 continue
826 }
827 if err := tailSessionFile(ctx, sessionPath, cfg.MirrorReasoning, func(ml mirrorLine) {
828 for _, line := range splitMirrorText(ml.Text) {
829 if line == "" {
830 continue
831 }
832 if len(ml.Meta) > 0 {
833 _ = relay.PostWithMeta(ctx, line, ml.Meta)
834 } else {
835 _ = relay.Post(ctx, line)
836 }
837 }
838 }); err != nil && ctx.Err() == nil {
839 time.Sleep(5 * time.Second)
840 continue
841 }
842 return
843 }
844 }
845
846 func discoverSessionPath(ctx context.Context, cfg config, startedAt time.Time, preExisting map[string]struct{}) (string, error) {
847 root, err := codexSessionsRoot()
848 if err != nil {
849 return "", err
850 }
851
@@ -838,11 +855,11 @@
855 })
856 }
857
858 target := filepath.Clean(cfg.TargetCWD)
859 return waitForSessionPath(ctx, func() (string, error) {
860 return findLatestSessionPath(root, target, startedAt.Add(-2*time.Second), preExisting)
861 })
862 }
863
864 func waitForSessionPath(ctx context.Context, find func() (string, error)) (string, error) {
865 ctx, cancel := context.WithTimeout(ctx, defaultDiscoverWait)
@@ -862,11 +879,11 @@
879 case <-ticker.C:
880 }
881 }
882 }
883
884 func tailSessionFile(ctx context.Context, path string, mirrorReasoning bool, emit func(mirrorLine)) error {
885 file, err := os.Open(path)
886 if err != nil {
887 return err
888 }
889 defer file.Close()
@@ -877,13 +894,13 @@
894
895 reader := bufio.NewReader(file)
896 for {
897 line, err := reader.ReadBytes('\n')
898 if len(line) > 0 {
899 for _, ml := range sessionMessages(line, mirrorReasoning) {
900 if ml.Text != "" {
901 emit(ml)
902 }
903 }
904 }
905 if err == nil {
906 continue
@@ -898,11 +915,11 @@
915 }
916 return err
917 }
918 }
919
920 func sessionMessages(line []byte, mirrorReasoning bool) []mirrorLine {
921 var env sessionEnvelope
922 if err := json.Unmarshal(line, &env); err != nil {
923 return nil
924 }
925 if env.Type != "response_item" {
@@ -915,24 +932,46 @@
932 }
933
934 switch payload.Type {
935 case "function_call":
936 if msg := summarizeFunctionCall(payload.Name, payload.Arguments); msg != "" {
937 meta := codexToolMeta(payload.Name, payload.Arguments)
938 return []mirrorLine{{Text: msg, Meta: meta}}
939 }
940 case "custom_tool_call":
941 if msg := summarizeCustomToolCall(payload.Name, payload.Input); msg != "" {
942 meta := codexToolMeta(payload.Name, payload.Input)
943 return []mirrorLine{{Text: msg, Meta: meta}}
944 }
945 case "message":
946 if payload.Role != "assistant" {
947 return nil
948 }
949 return flattenAssistantContent(payload.Content, mirrorReasoning)
950 }
951 return nil
952 }
953
954 // codexToolMeta builds a JSON metadata envelope for a Codex tool call.
955 func codexToolMeta(name, argsJSON string) json.RawMessage {
956 data := map[string]string{"tool": name}
957 switch name {
958 case "exec_command":
959 var args execCommandArgs
960 if err := json.Unmarshal([]byte(argsJSON), &args); err == nil && args.Cmd != "" {
961 data["command"] = sanitizeSecrets(args.Cmd)
962 }
963 case "apply_patch":
964 files := patchTargets(argsJSON)
965 if len(files) > 0 {
966 data["file"] = files[0]
967 }
968 }
969 meta := map[string]any{"type": "tool_result", "data": data}
970 b, _ := json.Marshal(meta)
971 return b
972 }
973
974 func summarizeFunctionCall(name, argsJSON string) string {
975 switch name {
976 case "exec_command":
977 var args execCommandArgs
@@ -975,25 +1014,25 @@
1014 }
1015 return name
1016 }
1017 }
1018
1019 func flattenAssistantContent(content []sessionContent, mirrorReasoning bool) []mirrorLine {
1020 var lines []mirrorLine
1021 for _, item := range content {
1022 switch item.Type {
1023 case "output_text":
1024 for _, line := range splitMirrorText(item.Text) {
1025 if line != "" {
1026 lines = append(lines, mirrorLine{Text: line})
1027 }
1028 }
1029 case "reasoning":
1030 if mirrorReasoning {
1031 for _, line := range splitMirrorText(item.Text) {
1032 if line != "" {
1033 lines = append(lines, mirrorLine{Text: "💭 " + line})
1034 }
1035 }
1036 }
1037 }
1038 }
@@ -1070,10 +1109,25 @@
1109 return strings.TrimSpace(args[i+1])
1110 }
1111 }
1112 return ""
1113 }
1114
1115 // snapshotSessionFiles returns the set of .jsonl file paths currently under root.
1116 // Called before starting the Codex subprocess so discovery can skip pre-existing
1117 // sessions and deterministically find the one our subprocess creates.
1118 func snapshotSessionFiles(root string) map[string]struct{} {
1119 existing := make(map[string]struct{})
1120 _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
1121 if err != nil || d.IsDir() || !strings.HasSuffix(path, ".jsonl") {
1122 return nil
1123 }
1124 existing[path] = struct{}{}
1125 return nil
1126 })
1127 return existing
1128 }
1129
1130 func codexSessionsRoot() (string, error) {
1131 if value := os.Getenv("CODEX_HOME"); value != "" {
1132 return filepath.Join(value, "sessions"), nil
1133 }
@@ -1103,19 +1157,30 @@
1157 return "", os.ErrNotExist
1158 }
1159 return match, nil
1160 }
1161
1162 // findLatestSessionPath finds the .jsonl file in root that was created by our
1163 // subprocess. It uses a pre-existing file snapshot to skip sessions that
1164 // existed before the subprocess started, then filters by CWD and picks the
1165 // oldest new match. When preExisting is nil (reconnect), it falls back to
1166 // accepting any file whose timestamp is >= notBefore.
1167 func findLatestSessionPath(root, target string, notBefore time.Time, preExisting map[string]struct{}) (string, error) {
1168 type candidate struct {
1169 path string
1170 ts time.Time
1171 }
1172 var candidates []candidate
1173
1174 err := filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error {
1175 if walkErr != nil || d.IsDir() || !strings.HasSuffix(path, ".jsonl") {
1176 return nil
1177 }
1178 if preExisting != nil {
1179 if _, existed := preExisting[path]; existed {
1180 return nil
1181 }
1182 }
1183 meta, ts, err := readSessionMeta(path)
1184 if err != nil {
1185 return nil
1186 }
@@ -1123,23 +1188,25 @@
1188 return nil
1189 }
1190 if ts.Before(notBefore) {
1191 return nil
1192 }
1193 candidates = append(candidates, candidate{path: path, ts: ts})
 
 
 
1194 return nil
1195 })
1196 if err != nil {
1197 return "", err
1198 }
1199 if len(candidates) == 0 {
1200 return "", os.ErrNotExist
1201 }
1202 // Pick the oldest new session — the first file created after our
1203 // subprocess started is most likely ours.
1204 sort.Slice(candidates, func(i, j int) bool {
1205 return candidates[i].ts.Before(candidates[j].ts)
1206 })
1207 return candidates[0].path, nil
1208 }
1209
1210 func readSessionMeta(path string) (sessionMetaPayload, time.Time, error) {
1211 file, err := os.Open(path)
1212 if err != nil {
1213
--- cmd/codex-relay/main_test.go
+++ cmd/codex-relay/main_test.go
@@ -1,9 +1,11 @@
11
package main
22
33
import (
44
"bytes"
5
+ "fmt"
6
+ "os"
57
"path/filepath"
68
"strings"
79
"testing"
810
"time"
911
)
@@ -155,33 +157,33 @@
155157
func TestSessionMessagesFunctionCallAndAssistant(t *testing.T) {
156158
t.Helper()
157159
158160
fnLine := []byte(`{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"pwd\"}"}}`)
159161
got := sessionMessages(fnLine, false)
160
- if len(got) != 1 || got[0] != "› pwd" {
162
+ if len(got) != 1 || got[0].Text != "› pwd" {
161163
t.Fatalf("sessionMessages function_call = %#v", got)
162164
}
163165
164166
msgLine := []byte(`{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"one line\nsecond line"}]}}`)
165167
got = sessionMessages(msgLine, false)
166
- if len(got) != 2 || got[0] != "one line" || got[1] != "second line" {
168
+ if len(got) != 2 || got[0].Text != "one line" || got[1].Text != "second line" {
167169
t.Fatalf("sessionMessages assistant = %#v", got)
168170
}
169171
}
170172
171173
func TestSessionMessagesReasoning(t *testing.T) {
172174
line := []byte(`{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"reasoning","text":"thinking hard"},{"type":"output_text","text":"done"}]}}`)
173175
174176
// reasoning off — only output_text
175177
got := sessionMessages(line, false)
176
- if len(got) != 1 || got[0] != "done" {
178
+ if len(got) != 1 || got[0].Text != "done" {
177179
t.Fatalf("mirrorReasoning=false: got %#v", got)
178180
}
179181
180182
// reasoning on — both, reasoning prefixed
181183
got = sessionMessages(line, true)
182
- if len(got) != 2 || got[0] != "💭 thinking hard" || got[1] != "done" {
184
+ if len(got) != 2 || got[0].Text != "💭 thinking hard" || got[1].Text != "done" {
183185
t.Fatalf("mirrorReasoning=true: got %#v", got)
184186
}
185187
}
186188
187189
func TestExplicitThreadID(t *testing.T) {
@@ -191,5 +193,120 @@
191193
want := "019d45e1-8328-7261-9a02-5c4304e07724"
192194
if got != want {
193195
t.Fatalf("explicitThreadID = %q, want %q", got, want)
194196
}
195197
}
198
+
199
+func writeSessionFile(t *testing.T, dir, uuid, cwd, timestamp string) string {
200
+ t.Helper()
201
+ content := fmt.Sprintf(`{"type":"session_meta","payload":{"id":"%s","timestamp":"%s","cwd":"%s"}}`, uuid, timestamp, cwd)
202
+ name := fmt.Sprintf("rollout-%s-%s.jsonl", strings.ReplaceAll(timestamp[:19], ":", "-"), uuid)
203
+ path := filepath.Join(dir, name)
204
+ if err := os.WriteFile(path, []byte(content+"\n"), 0644); err != nil {
205
+ t.Fatal(err)
206
+ }
207
+ return path
208
+}
209
+
210
+func TestFindLatestSessionPathSkipsPreExisting(t *testing.T) {
211
+ t.Helper()
212
+
213
+ root := t.TempDir()
214
+ dateDir := filepath.Join(root, "2026", "04", "04")
215
+ if err := os.MkdirAll(dateDir, 0755); err != nil {
216
+ t.Fatal(err)
217
+ }
218
+
219
+ cwd := "/home/user/project"
220
+
221
+ // Create a pre-existing session file.
222
+ oldPath := writeSessionFile(t, dateDir,
223
+ "aaaa-aaaa-aaaa-aaaa", cwd, "2026-04-04T10:00:00Z")
224
+
225
+ // Snapshot includes the old file.
226
+ preExisting := map[string]struct{}{oldPath: {}}
227
+
228
+ // Create a new session file (not in snapshot).
229
+ newPath := writeSessionFile(t, dateDir,
230
+ "bbbb-bbbb-bbbb-bbbb", cwd, "2026-04-04T10:00:01Z")
231
+
232
+ notBefore, _ := time.Parse(time.RFC3339, "2026-04-04T09:59:58Z")
233
+ got, err := findLatestSessionPath(root, cwd, notBefore, preExisting)
234
+ if err != nil {
235
+ t.Fatalf("findLatestSessionPath error: %v", err)
236
+ }
237
+ if got != newPath {
238
+ t.Fatalf("findLatestSessionPath = %q, want %q", got, newPath)
239
+ }
240
+}
241
+
242
+func TestFindLatestSessionPathPicksOldestNew(t *testing.T) {
243
+ t.Helper()
244
+
245
+ root := t.TempDir()
246
+ dateDir := filepath.Join(root, "2026", "04", "04")
247
+ if err := os.MkdirAll(dateDir, 0755); err != nil {
248
+ t.Fatal(err)
249
+ }
250
+
251
+ cwd := "/home/user/project"
252
+
253
+ // Two new sessions in the same CWD, no pre-existing.
254
+ earlyPath := writeSessionFile(t, dateDir,
255
+ "cccc-cccc-cccc-cccc", cwd, "2026-04-04T10:00:01Z")
256
+ _ = writeSessionFile(t, dateDir,
257
+ "dddd-dddd-dddd-dddd", cwd, "2026-04-04T10:00:02Z")
258
+
259
+ notBefore, _ := time.Parse(time.RFC3339, "2026-04-04T10:00:00Z")
260
+ got, err := findLatestSessionPath(root, cwd, notBefore, map[string]struct{}{})
261
+ if err != nil {
262
+ t.Fatalf("findLatestSessionPath error: %v", err)
263
+ }
264
+ if got != earlyPath {
265
+ t.Fatalf("findLatestSessionPath = %q, want oldest %q", got, earlyPath)
266
+ }
267
+}
268
+
269
+func TestFindLatestSessionPathNilPreExistingAllowsAll(t *testing.T) {
270
+ t.Helper()
271
+
272
+ root := t.TempDir()
273
+ dateDir := filepath.Join(root, "2026", "04", "04")
274
+ if err := os.MkdirAll(dateDir, 0755); err != nil {
275
+ t.Fatal(err)
276
+ }
277
+
278
+ cwd := "/home/user/project"
279
+
280
+ // Single file — nil preExisting (reconnect path) should find it.
281
+ path := writeSessionFile(t, dateDir,
282
+ "eeee-eeee-eeee-eeee", cwd, "2026-04-04T10:00:00Z")
283
+
284
+ got, err := findLatestSessionPath(root, cwd, time.Time{}, nil)
285
+ if err != nil {
286
+ t.Fatalf("findLatestSessionPath error: %v", err)
287
+ }
288
+ if got != path {
289
+ t.Fatalf("findLatestSessionPath = %q, want %q", got, path)
290
+ }
291
+}
292
+
293
+func TestSnapshotSessionFiles(t *testing.T) {
294
+ t.Helper()
295
+
296
+ root := t.TempDir()
297
+ dateDir := filepath.Join(root, "2026", "04", "04")
298
+ if err := os.MkdirAll(dateDir, 0755); err != nil {
299
+ t.Fatal(err)
300
+ }
301
+
302
+ path := writeSessionFile(t, dateDir,
303
+ "ffff-ffff-ffff-ffff", "/tmp", "2026-04-04T10:00:00Z")
304
+
305
+ snap := snapshotSessionFiles(root)
306
+ if _, ok := snap[path]; !ok {
307
+ t.Fatalf("snapshotSessionFiles missing %q", path)
308
+ }
309
+ if len(snap) != 1 {
310
+ t.Fatalf("snapshotSessionFiles len = %d, want 1", len(snap))
311
+ }
312
+}
196313
--- cmd/codex-relay/main_test.go
+++ cmd/codex-relay/main_test.go
@@ -1,9 +1,11 @@
1 package main
2
3 import (
4 "bytes"
 
 
5 "path/filepath"
6 "strings"
7 "testing"
8 "time"
9 )
@@ -155,33 +157,33 @@
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) {
@@ -191,5 +193,120 @@
191 want := "019d45e1-8328-7261-9a02-5c4304e07724"
192 if got != want {
193 t.Fatalf("explicitThreadID = %q, want %q", got, want)
194 }
195 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
196
--- cmd/codex-relay/main_test.go
+++ cmd/codex-relay/main_test.go
@@ -1,9 +1,11 @@
1 package main
2
3 import (
4 "bytes"
5 "fmt"
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10 "time"
11 )
@@ -155,33 +157,33 @@
157 func TestSessionMessagesFunctionCallAndAssistant(t *testing.T) {
158 t.Helper()
159
160 fnLine := []byte(`{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"pwd\"}"}}`)
161 got := sessionMessages(fnLine, false)
162 if len(got) != 1 || got[0].Text != "› pwd" {
163 t.Fatalf("sessionMessages function_call = %#v", got)
164 }
165
166 msgLine := []byte(`{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"one line\nsecond line"}]}}`)
167 got = sessionMessages(msgLine, false)
168 if len(got) != 2 || got[0].Text != "one line" || got[1].Text != "second line" {
169 t.Fatalf("sessionMessages assistant = %#v", got)
170 }
171 }
172
173 func TestSessionMessagesReasoning(t *testing.T) {
174 line := []byte(`{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"reasoning","text":"thinking hard"},{"type":"output_text","text":"done"}]}}`)
175
176 // reasoning off — only output_text
177 got := sessionMessages(line, false)
178 if len(got) != 1 || got[0].Text != "done" {
179 t.Fatalf("mirrorReasoning=false: got %#v", got)
180 }
181
182 // reasoning on — both, reasoning prefixed
183 got = sessionMessages(line, true)
184 if len(got) != 2 || got[0].Text != "💭 thinking hard" || got[1].Text != "done" {
185 t.Fatalf("mirrorReasoning=true: got %#v", got)
186 }
187 }
188
189 func TestExplicitThreadID(t *testing.T) {
@@ -191,5 +193,120 @@
193 want := "019d45e1-8328-7261-9a02-5c4304e07724"
194 if got != want {
195 t.Fatalf("explicitThreadID = %q, want %q", got, want)
196 }
197 }
198
199 func writeSessionFile(t *testing.T, dir, uuid, cwd, timestamp string) string {
200 t.Helper()
201 content := fmt.Sprintf(`{"type":"session_meta","payload":{"id":"%s","timestamp":"%s","cwd":"%s"}}`, uuid, timestamp, cwd)
202 name := fmt.Sprintf("rollout-%s-%s.jsonl", strings.ReplaceAll(timestamp[:19], ":", "-"), uuid)
203 path := filepath.Join(dir, name)
204 if err := os.WriteFile(path, []byte(content+"\n"), 0644); err != nil {
205 t.Fatal(err)
206 }
207 return path
208 }
209
210 func TestFindLatestSessionPathSkipsPreExisting(t *testing.T) {
211 t.Helper()
212
213 root := t.TempDir()
214 dateDir := filepath.Join(root, "2026", "04", "04")
215 if err := os.MkdirAll(dateDir, 0755); err != nil {
216 t.Fatal(err)
217 }
218
219 cwd := "/home/user/project"
220
221 // Create a pre-existing session file.
222 oldPath := writeSessionFile(t, dateDir,
223 "aaaa-aaaa-aaaa-aaaa", cwd, "2026-04-04T10:00:00Z")
224
225 // Snapshot includes the old file.
226 preExisting := map[string]struct{}{oldPath: {}}
227
228 // Create a new session file (not in snapshot).
229 newPath := writeSessionFile(t, dateDir,
230 "bbbb-bbbb-bbbb-bbbb", cwd, "2026-04-04T10:00:01Z")
231
232 notBefore, _ := time.Parse(time.RFC3339, "2026-04-04T09:59:58Z")
233 got, err := findLatestSessionPath(root, cwd, notBefore, preExisting)
234 if err != nil {
235 t.Fatalf("findLatestSessionPath error: %v", err)
236 }
237 if got != newPath {
238 t.Fatalf("findLatestSessionPath = %q, want %q", got, newPath)
239 }
240 }
241
242 func TestFindLatestSessionPathPicksOldestNew(t *testing.T) {
243 t.Helper()
244
245 root := t.TempDir()
246 dateDir := filepath.Join(root, "2026", "04", "04")
247 if err := os.MkdirAll(dateDir, 0755); err != nil {
248 t.Fatal(err)
249 }
250
251 cwd := "/home/user/project"
252
253 // Two new sessions in the same CWD, no pre-existing.
254 earlyPath := writeSessionFile(t, dateDir,
255 "cccc-cccc-cccc-cccc", cwd, "2026-04-04T10:00:01Z")
256 _ = writeSessionFile(t, dateDir,
257 "dddd-dddd-dddd-dddd", cwd, "2026-04-04T10:00:02Z")
258
259 notBefore, _ := time.Parse(time.RFC3339, "2026-04-04T10:00:00Z")
260 got, err := findLatestSessionPath(root, cwd, notBefore, map[string]struct{}{})
261 if err != nil {
262 t.Fatalf("findLatestSessionPath error: %v", err)
263 }
264 if got != earlyPath {
265 t.Fatalf("findLatestSessionPath = %q, want oldest %q", got, earlyPath)
266 }
267 }
268
269 func TestFindLatestSessionPathNilPreExistingAllowsAll(t *testing.T) {
270 t.Helper()
271
272 root := t.TempDir()
273 dateDir := filepath.Join(root, "2026", "04", "04")
274 if err := os.MkdirAll(dateDir, 0755); err != nil {
275 t.Fatal(err)
276 }
277
278 cwd := "/home/user/project"
279
280 // Single file — nil preExisting (reconnect path) should find it.
281 path := writeSessionFile(t, dateDir,
282 "eeee-eeee-eeee-eeee", cwd, "2026-04-04T10:00:00Z")
283
284 got, err := findLatestSessionPath(root, cwd, time.Time{}, nil)
285 if err != nil {
286 t.Fatalf("findLatestSessionPath error: %v", err)
287 }
288 if got != path {
289 t.Fatalf("findLatestSessionPath = %q, want %q", got, path)
290 }
291 }
292
293 func TestSnapshotSessionFiles(t *testing.T) {
294 t.Helper()
295
296 root := t.TempDir()
297 dateDir := filepath.Join(root, "2026", "04", "04")
298 if err := os.MkdirAll(dateDir, 0755); err != nil {
299 t.Fatal(err)
300 }
301
302 path := writeSessionFile(t, dateDir,
303 "ffff-ffff-ffff-ffff", "/tmp", "2026-04-04T10:00:00Z")
304
305 snap := snapshotSessionFiles(root)
306 if _, ok := snap[path]; !ok {
307 t.Fatalf("snapshotSessionFiles missing %q", path)
308 }
309 if len(snap) != 1 {
310 t.Fatalf("snapshotSessionFiles len = %d, want 1", len(snap))
311 }
312 }
313
--- cmd/gemini-relay/main.go
+++ cmd/gemini-relay/main.go
@@ -478,11 +478,10 @@
478478
if err != nil {
479479
return
480480
}
481481
}
482482
}
483
-
484483
485484
func (s *relayState) observeOutput(data []byte, now time.Time) {
486485
if s == nil {
487486
return
488487
}
489488
--- cmd/gemini-relay/main.go
+++ cmd/gemini-relay/main.go
@@ -478,11 +478,10 @@
478 if err != nil {
479 return
480 }
481 }
482 }
483
484
485 func (s *relayState) observeOutput(data []byte, now time.Time) {
486 if s == nil {
487 return
488 }
489
--- cmd/gemini-relay/main.go
+++ cmd/gemini-relay/main.go
@@ -478,11 +478,10 @@
478 if err != nil {
479 return
480 }
481 }
482 }
 
483
484 func (s *relayState) observeOutput(data []byte, now time.Time) {
485 if s == nil {
486 return
487 }
488
--- cmd/scuttlebot/main.go
+++ cmd/scuttlebot/main.go
@@ -345,11 +345,18 @@
345345
// Start HTTP REST API server.
346346
var llmCfg *config.LLMConfig
347347
if len(cfg.LLM.Backends) > 0 {
348348
llmCfg = &cfg.LLM
349349
}
350
- apiSrv := api.New(reg, tokens, bridgeBot, policyStore, adminStore, llmCfg, topoMgr, cfgStore, cfg.TLS.Domain, log)
350
+ // Pass an explicit nil interface when topology is not configured.
351
+ // A nil *topology.Manager passed as a topologyManager interface is
352
+ // non-nil (Go nil interface trap) and causes panics in setAgentModes.
353
+ var topoIface api.TopologyManager
354
+ if topoMgr != nil {
355
+ topoIface = topoMgr
356
+ }
357
+ apiSrv := api.New(reg, tokens, bridgeBot, policyStore, adminStore, llmCfg, topoIface, cfgStore, cfg.TLS.Domain, log)
351358
handler := apiSrv.Handler()
352359
353360
var httpServer, tlsServer *http.Server
354361
355362
if cfg.TLS.Domain != "" {
356363
--- cmd/scuttlebot/main.go
+++ cmd/scuttlebot/main.go
@@ -345,11 +345,18 @@
345 // Start HTTP REST API server.
346 var llmCfg *config.LLMConfig
347 if len(cfg.LLM.Backends) > 0 {
348 llmCfg = &cfg.LLM
349 }
350 apiSrv := api.New(reg, tokens, bridgeBot, policyStore, adminStore, llmCfg, topoMgr, cfgStore, cfg.TLS.Domain, log)
 
 
 
 
 
 
 
351 handler := apiSrv.Handler()
352
353 var httpServer, tlsServer *http.Server
354
355 if cfg.TLS.Domain != "" {
356
--- cmd/scuttlebot/main.go
+++ cmd/scuttlebot/main.go
@@ -345,11 +345,18 @@
345 // Start HTTP REST API server.
346 var llmCfg *config.LLMConfig
347 if len(cfg.LLM.Backends) > 0 {
348 llmCfg = &cfg.LLM
349 }
350 // Pass an explicit nil interface when topology is not configured.
351 // A nil *topology.Manager passed as a topologyManager interface is
352 // non-nil (Go nil interface trap) and causes panics in setAgentModes.
353 var topoIface api.TopologyManager
354 if topoMgr != nil {
355 topoIface = topoMgr
356 }
357 apiSrv := api.New(reg, tokens, bridgeBot, policyStore, adminStore, llmCfg, topoIface, cfgStore, cfg.TLS.Domain, log)
358 handler := apiSrv.Handler()
359
360 var httpServer, tlsServer *http.Server
361
362 if cfg.TLS.Domain != "" {
363
--- internal/api/channels_topology.go
+++ internal/api/channels_topology.go
@@ -5,12 +5,13 @@
55
"net/http"
66
77
"github.com/conflicthq/scuttlebot/internal/topology"
88
)
99
10
-// topologyManager is the interface the API server uses to provision channels
10
+// TopologyManager is the interface the API server uses to provision channels
1111
// and query the channel policy. Satisfied by *topology.Manager.
12
+type TopologyManager = topologyManager
1213
type topologyManager interface {
1314
ProvisionChannel(ch topology.ChannelConfig) error
1415
DropChannel(channel string)
1516
Policy() *topology.Policy
1617
GrantAccess(nick, channel, level string)
1718
--- internal/api/channels_topology.go
+++ internal/api/channels_topology.go
@@ -5,12 +5,13 @@
5 "net/http"
6
7 "github.com/conflicthq/scuttlebot/internal/topology"
8 )
9
10 // topologyManager is the interface the API server uses to provision channels
11 // and query the channel policy. Satisfied by *topology.Manager.
 
12 type topologyManager interface {
13 ProvisionChannel(ch topology.ChannelConfig) error
14 DropChannel(channel string)
15 Policy() *topology.Policy
16 GrantAccess(nick, channel, level string)
17
--- internal/api/channels_topology.go
+++ internal/api/channels_topology.go
@@ -5,12 +5,13 @@
5 "net/http"
6
7 "github.com/conflicthq/scuttlebot/internal/topology"
8 )
9
10 // TopologyManager is the interface the API server uses to provision channels
11 // and query the channel policy. Satisfied by *topology.Manager.
12 type TopologyManager = topologyManager
13 type topologyManager interface {
14 ProvisionChannel(ch topology.ChannelConfig) error
15 DropChannel(channel string)
16 Policy() *topology.Policy
17 GrantAccess(nick, channel, level string)
18
--- internal/api/chat.go
+++ internal/api/chat.go
@@ -16,10 +16,11 @@
1616
JoinChannel(channel string)
1717
LeaveChannel(channel string)
1818
Messages(channel string) []bridge.Message
1919
Subscribe(channel string) (<-chan bridge.Message, func())
2020
Send(ctx context.Context, channel, text, senderNick string) error
21
+ SendWithMeta(ctx context.Context, channel, text, senderNick string, meta *bridge.Meta) error
2122
Stats() bridge.Stats
2223
TouchUser(channel, nick string)
2324
Users(channel string) []string
2425
}
2526
@@ -64,22 +65,23 @@
6465
}
6566
6667
func (s *Server) handleSendMessage(w http.ResponseWriter, r *http.Request) {
6768
channel := "#" + r.PathValue("channel")
6869
var req struct {
69
- Text string `json:"text"`
70
- Nick string `json:"nick"`
70
+ Text string `json:"text"`
71
+ Nick string `json:"nick"`
72
+ Meta *bridge.Meta `json:"meta,omitempty"`
7173
}
7274
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
7375
writeError(w, http.StatusBadRequest, "invalid request body")
7476
return
7577
}
7678
if req.Text == "" {
7779
writeError(w, http.StatusBadRequest, "text is required")
7880
return
7981
}
80
- if err := s.bridge.Send(r.Context(), channel, req.Text, req.Nick); err != nil {
82
+ if err := s.bridge.SendWithMeta(r.Context(), channel, req.Text, req.Nick, req.Meta); err != nil {
8183
s.log.Error("bridge send", "channel", channel, "err", err)
8284
writeError(w, http.StatusInternalServerError, "send failed")
8385
return
8486
}
8587
w.WriteHeader(http.StatusNoContent)
8688
--- internal/api/chat.go
+++ internal/api/chat.go
@@ -16,10 +16,11 @@
16 JoinChannel(channel string)
17 LeaveChannel(channel string)
18 Messages(channel string) []bridge.Message
19 Subscribe(channel string) (<-chan bridge.Message, func())
20 Send(ctx context.Context, channel, text, senderNick string) error
 
21 Stats() bridge.Stats
22 TouchUser(channel, nick string)
23 Users(channel string) []string
24 }
25
@@ -64,22 +65,23 @@
64 }
65
66 func (s *Server) handleSendMessage(w http.ResponseWriter, r *http.Request) {
67 channel := "#" + r.PathValue("channel")
68 var req struct {
69 Text string `json:"text"`
70 Nick string `json:"nick"`
 
71 }
72 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
73 writeError(w, http.StatusBadRequest, "invalid request body")
74 return
75 }
76 if req.Text == "" {
77 writeError(w, http.StatusBadRequest, "text is required")
78 return
79 }
80 if err := s.bridge.Send(r.Context(), channel, req.Text, req.Nick); err != nil {
81 s.log.Error("bridge send", "channel", channel, "err", err)
82 writeError(w, http.StatusInternalServerError, "send failed")
83 return
84 }
85 w.WriteHeader(http.StatusNoContent)
86
--- internal/api/chat.go
+++ internal/api/chat.go
@@ -16,10 +16,11 @@
16 JoinChannel(channel string)
17 LeaveChannel(channel string)
18 Messages(channel string) []bridge.Message
19 Subscribe(channel string) (<-chan bridge.Message, func())
20 Send(ctx context.Context, channel, text, senderNick string) error
21 SendWithMeta(ctx context.Context, channel, text, senderNick string, meta *bridge.Meta) error
22 Stats() bridge.Stats
23 TouchUser(channel, nick string)
24 Users(channel string) []string
25 }
26
@@ -64,22 +65,23 @@
65 }
66
67 func (s *Server) handleSendMessage(w http.ResponseWriter, r *http.Request) {
68 channel := "#" + r.PathValue("channel")
69 var req struct {
70 Text string `json:"text"`
71 Nick string `json:"nick"`
72 Meta *bridge.Meta `json:"meta,omitempty"`
73 }
74 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
75 writeError(w, http.StatusBadRequest, "invalid request body")
76 return
77 }
78 if req.Text == "" {
79 writeError(w, http.StatusBadRequest, "text is required")
80 return
81 }
82 if err := s.bridge.SendWithMeta(r.Context(), channel, req.Text, req.Nick, req.Meta); err != nil {
83 s.log.Error("bridge send", "channel", channel, "err", err)
84 writeError(w, http.StatusInternalServerError, "send failed")
85 return
86 }
87 w.WriteHeader(http.StatusNoContent)
88
--- internal/api/chat_test.go
+++ internal/api/chat_test.go
@@ -27,12 +27,15 @@
2727
func (b *stubChatBridge) Messages(string) []bridge.Message { return nil }
2828
func (b *stubChatBridge) Subscribe(string) (<-chan bridge.Message, func()) {
2929
return make(chan bridge.Message), func() {}
3030
}
3131
func (b *stubChatBridge) Send(context.Context, string, string, string) error { return nil }
32
-func (b *stubChatBridge) Stats() bridge.Stats { return bridge.Stats{} }
33
-func (b *stubChatBridge) Users(string) []string { return nil }
32
+func (b *stubChatBridge) SendWithMeta(_ context.Context, _, _, _ string, _ *bridge.Meta) error {
33
+ return nil
34
+}
35
+func (b *stubChatBridge) Stats() bridge.Stats { return bridge.Stats{} }
36
+func (b *stubChatBridge) Users(string) []string { return nil }
3437
func (b *stubChatBridge) TouchUser(channel, nick string) {
3538
b.touched = append(b.touched, struct{ channel, nick string }{channel: channel, nick: nick})
3639
}
3740
3841
func TestHandleChannelPresence(t *testing.T) {
3942
--- internal/api/chat_test.go
+++ internal/api/chat_test.go
@@ -27,12 +27,15 @@
27 func (b *stubChatBridge) Messages(string) []bridge.Message { return nil }
28 func (b *stubChatBridge) Subscribe(string) (<-chan bridge.Message, func()) {
29 return make(chan bridge.Message), func() {}
30 }
31 func (b *stubChatBridge) Send(context.Context, string, string, string) error { return nil }
32 func (b *stubChatBridge) Stats() bridge.Stats { return bridge.Stats{} }
33 func (b *stubChatBridge) Users(string) []string { return nil }
 
 
 
34 func (b *stubChatBridge) TouchUser(channel, nick string) {
35 b.touched = append(b.touched, struct{ channel, nick string }{channel: channel, nick: nick})
36 }
37
38 func TestHandleChannelPresence(t *testing.T) {
39
--- internal/api/chat_test.go
+++ internal/api/chat_test.go
@@ -27,12 +27,15 @@
27 func (b *stubChatBridge) Messages(string) []bridge.Message { return nil }
28 func (b *stubChatBridge) Subscribe(string) (<-chan bridge.Message, func()) {
29 return make(chan bridge.Message), func() {}
30 }
31 func (b *stubChatBridge) Send(context.Context, string, string, string) error { return nil }
32 func (b *stubChatBridge) SendWithMeta(_ context.Context, _, _, _ string, _ *bridge.Meta) error {
33 return nil
34 }
35 func (b *stubChatBridge) Stats() bridge.Stats { return bridge.Stats{} }
36 func (b *stubChatBridge) Users(string) []string { return nil }
37 func (b *stubChatBridge) TouchUser(channel, nick string) {
38 b.touched = append(b.touched, struct{ channel, nick string }{channel: channel, nick: nick})
39 }
40
41 func TestHandleChannelPresence(t *testing.T) {
42
--- internal/api/ui/index.html
+++ internal/api/ui/index.html
@@ -142,10 +142,11 @@
142142
.chat-ch-name { font-weight:600; color:#58a6ff; }
143143
.stream-badge { font-size:11px; color:#8b949e; margin-left:auto; }
144144
.chat-msgs { flex:1; overflow-y:auto; padding:4px 8px; display:flex; flex-direction:column; gap:0; }
145145
.msg-row { font-size:13px; line-height:1.4; padding:1px 0; }
146146
.msg-time { color:#8b949e; font-size:11px; margin-right:6px; }
147
+.chat-msgs.hide-timestamps .msg-time { display:none; }
147148
.msg-nick { font-weight:600; margin-right:6px; }
148149
.msg-grouped .msg-nick { display:none; }
149150
.msg-grouped .msg-time { display:none; }
150151
/* columnar layout mode */
151152
.chat-msgs.columnar .msg-row { display:flex; gap:6px; }
@@ -175,10 +176,29 @@
175176
.msg-text { color:#e6edf3; word-break:break-word; }
176177
.msg-row.hl-mention { background:#1f6feb18; border-left:2px solid #58a6ff; padding-left:6px; }
177178
.msg-row.hl-danger { background:#f8514918; border-left:2px solid #f85149; padding-left:6px; }
178179
.msg-row.hl-system { opacity:0.6; font-style:italic; }
179180
.msg-text .hl-word { background:#f0883e33; border-radius:2px; padding:0 2px; }
181
+/* meta blocks */
182
+.msg-meta { display:none; margin:2px 0 4px 0; padding:8px 10px; background:#0d1117; border:1px solid #21262d; border-radius:6px; font-size:12px; line-height:1.5; cursor:default; }
183
+.msg-meta.open { display:block; }
184
+.msg-meta-toggle { display:inline-block; margin-left:6px; font-size:10px; color:#8b949e; cursor:pointer; padding:0 4px; border:1px solid #30363d; border-radius:3px; vertical-align:middle; }
185
+.msg-meta-toggle:hover { color:#e6edf3; border-color:#58a6ff; }
186
+.msg-meta .meta-type { font-size:10px; text-transform:uppercase; letter-spacing:.06em; color:#8b949e; margin-bottom:4px; }
187
+.msg-meta .meta-tool { color:#d2a8ff; font-weight:600; }
188
+.msg-meta .meta-file { color:#79c0ff; }
189
+.msg-meta .meta-cmd { color:#a5d6ff; font-family:inherit; }
190
+.msg-meta .meta-error { color:#ff7b72; }
191
+.msg-meta .meta-status { display:inline-block; padding:1px 6px; border-radius:3px; font-size:11px; }
192
+.msg-meta .meta-status.ok { background:#3fb95022; color:#3fb950; border:1px solid #3fb95044; }
193
+.msg-meta .meta-status.error { background:#f8514922; color:#f85149; border:1px solid #f8514944; }
194
+.msg-meta .meta-status.running { background:#1f6feb22; color:#58a6ff; border:1px solid #1f6feb44; }
195
+.msg-meta .meta-kv { display:grid; grid-template-columns:auto 1fr; gap:2px 10px; }
196
+.msg-meta .meta-kv dt { color:#8b949e; }
197
+.msg-meta .meta-kv dd { color:#e6edf3; word-break:break-all; }
198
+.msg-meta pre { margin:4px 0 0; padding:6px 8px; background:#161b22; border:1px solid #21262d; border-radius:4px; overflow-x:auto; white-space:pre-wrap; word-break:break-all; color:#e6edf3; font-size:12px; }
199
+.msg-meta img { max-width:100%; max-height:300px; border-radius:4px; margin-top:4px; }
180200
.chat-input { padding:9px 13px; padding-bottom:calc(9px + env(safe-area-inset-bottom, 0px)); border-top:1px solid #30363d; display:flex; gap:7px; flex-shrink:0; background:#161b22; }
181201
182202
/* channels tab */
183203
.chan-card { display:flex; align-items:center; gap:12px; padding:12px 16px; border-bottom:1px solid #21262d; }
184204
.chan-card:last-child { border-bottom:none; }
@@ -491,10 +511,12 @@
491511
<span style="font-size:11px;color:#8b949e;margin-right:6px">chatting as</span>
492512
<select id="chat-identity" style="width:140px;padding:3px 6px;font-size:12px" onchange="saveChatIdentity()">
493513
<option value="">— pick a user —</option>
494514
</select>
495515
<button class="sm" id="chat-layout-toggle" onclick="toggleChatLayout()" title="toggle compact/columnar" style="font-size:11px;padding:2px 6px">☰</button>
516
+ <button class="sm" id="chat-ts-toggle" onclick="toggleTimestamps()" title="toggle timestamps" style="font-size:11px;padding:2px 6px">🕐</button>
517
+ <button class="sm" id="chat-rich-toggle" onclick="toggleRichMode()" title="toggle rich/text mode" style="font-size:11px;padding:2px 6px">✨</button>
496518
<button class="sm" onclick="promptHighlightWords()" title="configure highlight keywords" style="font-size:11px;padding:2px 6px">✦</button>
497519
<span class="stream-badge" id="chat-stream-status" style="margin-left:8px"></span>
498520
</div>
499521
<div class="chat-msgs" id="chat-msgs">
500522
<div class="empty" id="chat-placeholder">join a channel to start chatting</div>
@@ -1888,14 +1910,26 @@
18881910
const timeStr = new Date(msg.at).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
18891911
const color = nickColor(displayNick);
18901912
18911913
const row = document.createElement('div');
18921914
row.className = 'msg-row' + (grouped ? ' msg-grouped' : '');
1915
+ // Build meta toggle if metadata present and rich mode is on.
1916
+ let metaToggle = '';
1917
+ let metaBlock = '';
1918
+ if (msg.meta && msg.meta.type) {
1919
+ const html = renderMeta(msg.meta);
1920
+ if (html) {
1921
+ const show = isRichMode();
1922
+ metaToggle = `<span class="msg-meta-toggle" style="${show ? '' : 'display:none'}" onclick="this.parentElement.nextElementSibling.classList.toggle('open');event.stopPropagation()">✨</span>`;
1923
+ metaBlock = `<div class="msg-meta">${html}</div>`;
1924
+ }
1925
+ }
1926
+
18931927
row.innerHTML =
18941928
`<span class="msg-time" title="${esc(new Date(msg.at).toLocaleString())}">${esc(timeStr)}</span>` +
18951929
`<span class="msg-nick" style="color:${color}">[${esc(displayNick)}]:</span>` +
1896
- `<span class="msg-text">${highlightText(esc(displayText))}</span>`;
1930
+ `<span class="msg-text">${highlightText(esc(displayText))}${metaToggle}</span>`;
18971931
18981932
// Apply row-level highlights.
18991933
const myNick = localStorage.getItem('sb_username') || '';
19001934
const lower = displayText.toLowerCase();
19011935
if (myNick && lower.includes(myNick.toLowerCase())) {
@@ -1907,10 +1941,16 @@
19071941
if (/\b(online|offline|reconnected|joined|parted)\b/i.test(displayText) && !displayText.includes(': ')) {
19081942
row.classList.add('hl-system');
19091943
}
19101944
19111945
area.appendChild(row);
1946
+ // Append meta block after the row so toggle can find it via nextElementSibling.
1947
+ if (metaBlock) {
1948
+ const metaEl = document.createElement('div');
1949
+ metaEl.innerHTML = metaBlock;
1950
+ area.appendChild(metaEl.firstChild);
1951
+ }
19121952
19131953
// Unread badge when chat tab not active
19141954
if (!isHistory && !document.getElementById('tab-chat').classList.contains('active')) {
19151955
_chatUnread++;
19161956
document.getElementById('tab-chat').dataset.unread = _chatUnread > 9 ? '9+' : _chatUnread;
@@ -1964,10 +2004,116 @@
19642004
}
19652005
// Restore layout preference on load.
19662006
if (localStorage.getItem('sb_chat_columnar') === '1') {
19672007
document.getElementById('chat-msgs').classList.add('columnar');
19682008
}
2009
+
2010
+// --- timestamp toggle ---
2011
+function toggleTimestamps() {
2012
+ const el = document.getElementById('chat-msgs');
2013
+ const hidden = el.classList.toggle('hide-timestamps');
2014
+ localStorage.setItem('sb_hide_timestamps', hidden ? '1' : '0');
2015
+ const btn = document.getElementById('chat-ts-toggle');
2016
+ btn.style.color = hidden ? '#8b949e' : '';
2017
+ btn.title = hidden ? 'timestamps hidden — click to show' : 'timestamps shown — click to hide';
2018
+}
2019
+(function() {
2020
+ const hidden = localStorage.getItem('sb_hide_timestamps') === '1';
2021
+ if (hidden) document.getElementById('chat-msgs').classList.add('hide-timestamps');
2022
+ const btn = document.getElementById('chat-ts-toggle');
2023
+ if (hidden) { btn.style.color = '#8b949e'; btn.title = 'timestamps hidden — click to show'; }
2024
+ else { btn.title = 'timestamps shown — click to hide'; }
2025
+})();
2026
+
2027
+// --- rich mode toggle ---
2028
+function isRichMode() { return localStorage.getItem('sb_rich_mode') === '1'; }
2029
+function applyRichToggleStyle(btn, on) {
2030
+ if (on) {
2031
+ btn.style.background = '#1f6feb';
2032
+ btn.style.borderColor = '#1f6feb';
2033
+ btn.style.color = '#fff';
2034
+ btn.title = 'rich mode ON — click for text only';
2035
+ } else {
2036
+ btn.style.background = '';
2037
+ btn.style.borderColor = '';
2038
+ btn.style.color = '#8b949e';
2039
+ btn.title = 'text only — click for rich mode';
2040
+ }
2041
+}
2042
+function toggleRichMode() {
2043
+ const on = !isRichMode();
2044
+ localStorage.setItem('sb_rich_mode', on ? '1' : '0');
2045
+ const btn = document.getElementById('chat-rich-toggle');
2046
+ applyRichToggleStyle(btn, on);
2047
+ // Toggle all existing meta blocks visibility.
2048
+ document.querySelectorAll('.msg-meta-toggle').forEach(el => { el.style.display = on ? '' : 'none'; });
2049
+ if (!on) document.querySelectorAll('.msg-meta.open').forEach(el => el.classList.remove('open'));
2050
+}
2051
+// Initialize toggle button state on load.
2052
+(function() {
2053
+ applyRichToggleStyle(document.getElementById('chat-rich-toggle'), isRichMode());
2054
+})();
2055
+
2056
+// --- meta renderers ---
2057
+function renderMeta(meta) {
2058
+ if (!meta || !meta.type || !meta.data) return null;
2059
+ switch (meta.type) {
2060
+ case 'tool_result': return renderToolResult(meta.data);
2061
+ case 'diff': return renderDiff(meta.data);
2062
+ case 'error': return renderError(meta.data);
2063
+ case 'status': return renderStatus(meta.data);
2064
+ case 'artifact': return renderArtifact(meta.data);
2065
+ case 'image': return renderImage(meta.data);
2066
+ default: return renderGeneric(meta);
2067
+ }
2068
+}
2069
+function renderToolResult(d) {
2070
+ let html = '<div class="meta-type">tool call</div><dl class="meta-kv">';
2071
+ html += '<dt>tool</dt><dd class="meta-tool">' + esc(d.tool || '?') + '</dd>';
2072
+ if (d.file) html += '<dt>file</dt><dd class="meta-file">' + esc(d.file) + '</dd>';
2073
+ if (d.command) html += '<dt>command</dt><dd class="meta-cmd">' + esc(d.command) + '</dd>';
2074
+ if (d.pattern) html += '<dt>pattern</dt><dd>' + esc(d.pattern) + '</dd>';
2075
+ if (d.query) html += '<dt>query</dt><dd>' + esc(d.query) + '</dd>';
2076
+ if (d.url) html += '<dt>url</dt><dd>' + esc(d.url) + '</dd>';
2077
+ if (d.result) html += '<dt>result</dt><dd>' + esc(d.result) + '</dd>';
2078
+ html += '</dl>';
2079
+ return html;
2080
+}
2081
+function renderDiff(d) {
2082
+ let html = '<div class="meta-type">diff</div>';
2083
+ if (d.file) html += '<div class="meta-file">' + esc(d.file) + '</div>';
2084
+ if (d.hunks) html += '<pre>' + esc(typeof d.hunks === 'string' ? d.hunks : JSON.stringify(d.hunks, null, 2)) + '</pre>';
2085
+ return html;
2086
+}
2087
+function renderError(d) {
2088
+ let html = '<div class="meta-type meta-error">error</div>';
2089
+ html += '<div class="meta-error">' + esc(d.message || '') + '</div>';
2090
+ if (d.stack) html += '<pre>' + esc(d.stack) + '</pre>';
2091
+ return html;
2092
+}
2093
+function renderStatus(d) {
2094
+ const state = (d.state || 'running').toLowerCase();
2095
+ const cls = state === 'ok' || state === 'success' || state === 'done' ? 'ok' : state === 'error' || state === 'failed' ? 'error' : 'running';
2096
+ let html = '<div class="meta-type">status</div>';
2097
+ html += '<span class="meta-status ' + cls + '">' + esc(d.state || '') + '</span>';
2098
+ if (d.message) html += ' <span>' + esc(d.message) + '</span>';
2099
+ return html;
2100
+}
2101
+function renderArtifact(d) {
2102
+ let html = '<div class="meta-type">artifact</div>';
2103
+ html += '<div class="meta-file">' + esc(d.name || d.path || '?') + '</div>';
2104
+ if (d.language) html += '<span class="tag perm">' + esc(d.language) + '</span>';
2105
+ return html;
2106
+}
2107
+function renderImage(d) {
2108
+ let html = '<div class="meta-type">image</div>';
2109
+ if (d.url) html += '<img src="' + esc(d.url) + '" alt="' + esc(d.alt || '') + '" loading="lazy">';
2110
+ return html;
2111
+}
2112
+function renderGeneric(meta) {
2113
+ return '<div class="meta-type">' + esc(meta.type) + '</div><pre>' + esc(JSON.stringify(meta.data, null, 2)) + '</pre>';
2114
+}
19692115
19702116
async function sendMsg() {
19712117
if (!chatChannel) return;
19722118
const input = document.getElementById('chat-text-input');
19732119
const nick = document.getElementById('chat-identity').value.trim() || 'web';
19742120
--- internal/api/ui/index.html
+++ internal/api/ui/index.html
@@ -142,10 +142,11 @@
142 .chat-ch-name { font-weight:600; color:#58a6ff; }
143 .stream-badge { font-size:11px; color:#8b949e; margin-left:auto; }
144 .chat-msgs { flex:1; overflow-y:auto; padding:4px 8px; display:flex; flex-direction:column; gap:0; }
145 .msg-row { font-size:13px; line-height:1.4; padding:1px 0; }
146 .msg-time { color:#8b949e; font-size:11px; margin-right:6px; }
 
147 .msg-nick { font-weight:600; margin-right:6px; }
148 .msg-grouped .msg-nick { display:none; }
149 .msg-grouped .msg-time { display:none; }
150 /* columnar layout mode */
151 .chat-msgs.columnar .msg-row { display:flex; gap:6px; }
@@ -175,10 +176,29 @@
175 .msg-text { color:#e6edf3; word-break:break-word; }
176 .msg-row.hl-mention { background:#1f6feb18; border-left:2px solid #58a6ff; padding-left:6px; }
177 .msg-row.hl-danger { background:#f8514918; border-left:2px solid #f85149; padding-left:6px; }
178 .msg-row.hl-system { opacity:0.6; font-style:italic; }
179 .msg-text .hl-word { background:#f0883e33; border-radius:2px; padding:0 2px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180 .chat-input { padding:9px 13px; padding-bottom:calc(9px + env(safe-area-inset-bottom, 0px)); border-top:1px solid #30363d; display:flex; gap:7px; flex-shrink:0; background:#161b22; }
181
182 /* channels tab */
183 .chan-card { display:flex; align-items:center; gap:12px; padding:12px 16px; border-bottom:1px solid #21262d; }
184 .chan-card:last-child { border-bottom:none; }
@@ -491,10 +511,12 @@
491 <span style="font-size:11px;color:#8b949e;margin-right:6px">chatting as</span>
492 <select id="chat-identity" style="width:140px;padding:3px 6px;font-size:12px" onchange="saveChatIdentity()">
493 <option value="">— pick a user —</option>
494 </select>
495 <button class="sm" id="chat-layout-toggle" onclick="toggleChatLayout()" title="toggle compact/columnar" style="font-size:11px;padding:2px 6px">☰</button>
 
 
496 <button class="sm" onclick="promptHighlightWords()" title="configure highlight keywords" style="font-size:11px;padding:2px 6px">✦</button>
497 <span class="stream-badge" id="chat-stream-status" style="margin-left:8px"></span>
498 </div>
499 <div class="chat-msgs" id="chat-msgs">
500 <div class="empty" id="chat-placeholder">join a channel to start chatting</div>
@@ -1888,14 +1910,26 @@
1888 const timeStr = new Date(msg.at).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
1889 const color = nickColor(displayNick);
1890
1891 const row = document.createElement('div');
1892 row.className = 'msg-row' + (grouped ? ' msg-grouped' : '');
 
 
 
 
 
 
 
 
 
 
 
 
1893 row.innerHTML =
1894 `<span class="msg-time" title="${esc(new Date(msg.at).toLocaleString())}">${esc(timeStr)}</span>` +
1895 `<span class="msg-nick" style="color:${color}">[${esc(displayNick)}]:</span>` +
1896 `<span class="msg-text">${highlightText(esc(displayText))}</span>`;
1897
1898 // Apply row-level highlights.
1899 const myNick = localStorage.getItem('sb_username') || '';
1900 const lower = displayText.toLowerCase();
1901 if (myNick && lower.includes(myNick.toLowerCase())) {
@@ -1907,10 +1941,16 @@
1907 if (/\b(online|offline|reconnected|joined|parted)\b/i.test(displayText) && !displayText.includes(': ')) {
1908 row.classList.add('hl-system');
1909 }
1910
1911 area.appendChild(row);
 
 
 
 
 
 
1912
1913 // Unread badge when chat tab not active
1914 if (!isHistory && !document.getElementById('tab-chat').classList.contains('active')) {
1915 _chatUnread++;
1916 document.getElementById('tab-chat').dataset.unread = _chatUnread > 9 ? '9+' : _chatUnread;
@@ -1964,10 +2004,116 @@
1964 }
1965 // Restore layout preference on load.
1966 if (localStorage.getItem('sb_chat_columnar') === '1') {
1967 document.getElementById('chat-msgs').classList.add('columnar');
1968 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1969
1970 async function sendMsg() {
1971 if (!chatChannel) return;
1972 const input = document.getElementById('chat-text-input');
1973 const nick = document.getElementById('chat-identity').value.trim() || 'web';
1974
--- internal/api/ui/index.html
+++ internal/api/ui/index.html
@@ -142,10 +142,11 @@
142 .chat-ch-name { font-weight:600; color:#58a6ff; }
143 .stream-badge { font-size:11px; color:#8b949e; margin-left:auto; }
144 .chat-msgs { flex:1; overflow-y:auto; padding:4px 8px; display:flex; flex-direction:column; gap:0; }
145 .msg-row { font-size:13px; line-height:1.4; padding:1px 0; }
146 .msg-time { color:#8b949e; font-size:11px; margin-right:6px; }
147 .chat-msgs.hide-timestamps .msg-time { display:none; }
148 .msg-nick { font-weight:600; margin-right:6px; }
149 .msg-grouped .msg-nick { display:none; }
150 .msg-grouped .msg-time { display:none; }
151 /* columnar layout mode */
152 .chat-msgs.columnar .msg-row { display:flex; gap:6px; }
@@ -175,10 +176,29 @@
176 .msg-text { color:#e6edf3; word-break:break-word; }
177 .msg-row.hl-mention { background:#1f6feb18; border-left:2px solid #58a6ff; padding-left:6px; }
178 .msg-row.hl-danger { background:#f8514918; border-left:2px solid #f85149; padding-left:6px; }
179 .msg-row.hl-system { opacity:0.6; font-style:italic; }
180 .msg-text .hl-word { background:#f0883e33; border-radius:2px; padding:0 2px; }
181 /* meta blocks */
182 .msg-meta { display:none; margin:2px 0 4px 0; padding:8px 10px; background:#0d1117; border:1px solid #21262d; border-radius:6px; font-size:12px; line-height:1.5; cursor:default; }
183 .msg-meta.open { display:block; }
184 .msg-meta-toggle { display:inline-block; margin-left:6px; font-size:10px; color:#8b949e; cursor:pointer; padding:0 4px; border:1px solid #30363d; border-radius:3px; vertical-align:middle; }
185 .msg-meta-toggle:hover { color:#e6edf3; border-color:#58a6ff; }
186 .msg-meta .meta-type { font-size:10px; text-transform:uppercase; letter-spacing:.06em; color:#8b949e; margin-bottom:4px; }
187 .msg-meta .meta-tool { color:#d2a8ff; font-weight:600; }
188 .msg-meta .meta-file { color:#79c0ff; }
189 .msg-meta .meta-cmd { color:#a5d6ff; font-family:inherit; }
190 .msg-meta .meta-error { color:#ff7b72; }
191 .msg-meta .meta-status { display:inline-block; padding:1px 6px; border-radius:3px; font-size:11px; }
192 .msg-meta .meta-status.ok { background:#3fb95022; color:#3fb950; border:1px solid #3fb95044; }
193 .msg-meta .meta-status.error { background:#f8514922; color:#f85149; border:1px solid #f8514944; }
194 .msg-meta .meta-status.running { background:#1f6feb22; color:#58a6ff; border:1px solid #1f6feb44; }
195 .msg-meta .meta-kv { display:grid; grid-template-columns:auto 1fr; gap:2px 10px; }
196 .msg-meta .meta-kv dt { color:#8b949e; }
197 .msg-meta .meta-kv dd { color:#e6edf3; word-break:break-all; }
198 .msg-meta pre { margin:4px 0 0; padding:6px 8px; background:#161b22; border:1px solid #21262d; border-radius:4px; overflow-x:auto; white-space:pre-wrap; word-break:break-all; color:#e6edf3; font-size:12px; }
199 .msg-meta img { max-width:100%; max-height:300px; border-radius:4px; margin-top:4px; }
200 .chat-input { padding:9px 13px; padding-bottom:calc(9px + env(safe-area-inset-bottom, 0px)); border-top:1px solid #30363d; display:flex; gap:7px; flex-shrink:0; background:#161b22; }
201
202 /* channels tab */
203 .chan-card { display:flex; align-items:center; gap:12px; padding:12px 16px; border-bottom:1px solid #21262d; }
204 .chan-card:last-child { border-bottom:none; }
@@ -491,10 +511,12 @@
511 <span style="font-size:11px;color:#8b949e;margin-right:6px">chatting as</span>
512 <select id="chat-identity" style="width:140px;padding:3px 6px;font-size:12px" onchange="saveChatIdentity()">
513 <option value="">— pick a user —</option>
514 </select>
515 <button class="sm" id="chat-layout-toggle" onclick="toggleChatLayout()" title="toggle compact/columnar" style="font-size:11px;padding:2px 6px">☰</button>
516 <button class="sm" id="chat-ts-toggle" onclick="toggleTimestamps()" title="toggle timestamps" style="font-size:11px;padding:2px 6px">🕐</button>
517 <button class="sm" id="chat-rich-toggle" onclick="toggleRichMode()" title="toggle rich/text mode" style="font-size:11px;padding:2px 6px">✨</button>
518 <button class="sm" onclick="promptHighlightWords()" title="configure highlight keywords" style="font-size:11px;padding:2px 6px">✦</button>
519 <span class="stream-badge" id="chat-stream-status" style="margin-left:8px"></span>
520 </div>
521 <div class="chat-msgs" id="chat-msgs">
522 <div class="empty" id="chat-placeholder">join a channel to start chatting</div>
@@ -1888,14 +1910,26 @@
1910 const timeStr = new Date(msg.at).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
1911 const color = nickColor(displayNick);
1912
1913 const row = document.createElement('div');
1914 row.className = 'msg-row' + (grouped ? ' msg-grouped' : '');
1915 // Build meta toggle if metadata present and rich mode is on.
1916 let metaToggle = '';
1917 let metaBlock = '';
1918 if (msg.meta && msg.meta.type) {
1919 const html = renderMeta(msg.meta);
1920 if (html) {
1921 const show = isRichMode();
1922 metaToggle = `<span class="msg-meta-toggle" style="${show ? '' : 'display:none'}" onclick="this.parentElement.nextElementSibling.classList.toggle('open');event.stopPropagation()">✨</span>`;
1923 metaBlock = `<div class="msg-meta">${html}</div>`;
1924 }
1925 }
1926
1927 row.innerHTML =
1928 `<span class="msg-time" title="${esc(new Date(msg.at).toLocaleString())}">${esc(timeStr)}</span>` +
1929 `<span class="msg-nick" style="color:${color}">[${esc(displayNick)}]:</span>` +
1930 `<span class="msg-text">${highlightText(esc(displayText))}${metaToggle}</span>`;
1931
1932 // Apply row-level highlights.
1933 const myNick = localStorage.getItem('sb_username') || '';
1934 const lower = displayText.toLowerCase();
1935 if (myNick && lower.includes(myNick.toLowerCase())) {
@@ -1907,10 +1941,16 @@
1941 if (/\b(online|offline|reconnected|joined|parted)\b/i.test(displayText) && !displayText.includes(': ')) {
1942 row.classList.add('hl-system');
1943 }
1944
1945 area.appendChild(row);
1946 // Append meta block after the row so toggle can find it via nextElementSibling.
1947 if (metaBlock) {
1948 const metaEl = document.createElement('div');
1949 metaEl.innerHTML = metaBlock;
1950 area.appendChild(metaEl.firstChild);
1951 }
1952
1953 // Unread badge when chat tab not active
1954 if (!isHistory && !document.getElementById('tab-chat').classList.contains('active')) {
1955 _chatUnread++;
1956 document.getElementById('tab-chat').dataset.unread = _chatUnread > 9 ? '9+' : _chatUnread;
@@ -1964,10 +2004,116 @@
2004 }
2005 // Restore layout preference on load.
2006 if (localStorage.getItem('sb_chat_columnar') === '1') {
2007 document.getElementById('chat-msgs').classList.add('columnar');
2008 }
2009
2010 // --- timestamp toggle ---
2011 function toggleTimestamps() {
2012 const el = document.getElementById('chat-msgs');
2013 const hidden = el.classList.toggle('hide-timestamps');
2014 localStorage.setItem('sb_hide_timestamps', hidden ? '1' : '0');
2015 const btn = document.getElementById('chat-ts-toggle');
2016 btn.style.color = hidden ? '#8b949e' : '';
2017 btn.title = hidden ? 'timestamps hidden — click to show' : 'timestamps shown — click to hide';
2018 }
2019 (function() {
2020 const hidden = localStorage.getItem('sb_hide_timestamps') === '1';
2021 if (hidden) document.getElementById('chat-msgs').classList.add('hide-timestamps');
2022 const btn = document.getElementById('chat-ts-toggle');
2023 if (hidden) { btn.style.color = '#8b949e'; btn.title = 'timestamps hidden — click to show'; }
2024 else { btn.title = 'timestamps shown — click to hide'; }
2025 })();
2026
2027 // --- rich mode toggle ---
2028 function isRichMode() { return localStorage.getItem('sb_rich_mode') === '1'; }
2029 function applyRichToggleStyle(btn, on) {
2030 if (on) {
2031 btn.style.background = '#1f6feb';
2032 btn.style.borderColor = '#1f6feb';
2033 btn.style.color = '#fff';
2034 btn.title = 'rich mode ON — click for text only';
2035 } else {
2036 btn.style.background = '';
2037 btn.style.borderColor = '';
2038 btn.style.color = '#8b949e';
2039 btn.title = 'text only — click for rich mode';
2040 }
2041 }
2042 function toggleRichMode() {
2043 const on = !isRichMode();
2044 localStorage.setItem('sb_rich_mode', on ? '1' : '0');
2045 const btn = document.getElementById('chat-rich-toggle');
2046 applyRichToggleStyle(btn, on);
2047 // Toggle all existing meta blocks visibility.
2048 document.querySelectorAll('.msg-meta-toggle').forEach(el => { el.style.display = on ? '' : 'none'; });
2049 if (!on) document.querySelectorAll('.msg-meta.open').forEach(el => el.classList.remove('open'));
2050 }
2051 // Initialize toggle button state on load.
2052 (function() {
2053 applyRichToggleStyle(document.getElementById('chat-rich-toggle'), isRichMode());
2054 })();
2055
2056 // --- meta renderers ---
2057 function renderMeta(meta) {
2058 if (!meta || !meta.type || !meta.data) return null;
2059 switch (meta.type) {
2060 case 'tool_result': return renderToolResult(meta.data);
2061 case 'diff': return renderDiff(meta.data);
2062 case 'error': return renderError(meta.data);
2063 case 'status': return renderStatus(meta.data);
2064 case 'artifact': return renderArtifact(meta.data);
2065 case 'image': return renderImage(meta.data);
2066 default: return renderGeneric(meta);
2067 }
2068 }
2069 function renderToolResult(d) {
2070 let html = '<div class="meta-type">tool call</div><dl class="meta-kv">';
2071 html += '<dt>tool</dt><dd class="meta-tool">' + esc(d.tool || '?') + '</dd>';
2072 if (d.file) html += '<dt>file</dt><dd class="meta-file">' + esc(d.file) + '</dd>';
2073 if (d.command) html += '<dt>command</dt><dd class="meta-cmd">' + esc(d.command) + '</dd>';
2074 if (d.pattern) html += '<dt>pattern</dt><dd>' + esc(d.pattern) + '</dd>';
2075 if (d.query) html += '<dt>query</dt><dd>' + esc(d.query) + '</dd>';
2076 if (d.url) html += '<dt>url</dt><dd>' + esc(d.url) + '</dd>';
2077 if (d.result) html += '<dt>result</dt><dd>' + esc(d.result) + '</dd>';
2078 html += '</dl>';
2079 return html;
2080 }
2081 function renderDiff(d) {
2082 let html = '<div class="meta-type">diff</div>';
2083 if (d.file) html += '<div class="meta-file">' + esc(d.file) + '</div>';
2084 if (d.hunks) html += '<pre>' + esc(typeof d.hunks === 'string' ? d.hunks : JSON.stringify(d.hunks, null, 2)) + '</pre>';
2085 return html;
2086 }
2087 function renderError(d) {
2088 let html = '<div class="meta-type meta-error">error</div>';
2089 html += '<div class="meta-error">' + esc(d.message || '') + '</div>';
2090 if (d.stack) html += '<pre>' + esc(d.stack) + '</pre>';
2091 return html;
2092 }
2093 function renderStatus(d) {
2094 const state = (d.state || 'running').toLowerCase();
2095 const cls = state === 'ok' || state === 'success' || state === 'done' ? 'ok' : state === 'error' || state === 'failed' ? 'error' : 'running';
2096 let html = '<div class="meta-type">status</div>';
2097 html += '<span class="meta-status ' + cls + '">' + esc(d.state || '') + '</span>';
2098 if (d.message) html += ' <span>' + esc(d.message) + '</span>';
2099 return html;
2100 }
2101 function renderArtifact(d) {
2102 let html = '<div class="meta-type">artifact</div>';
2103 html += '<div class="meta-file">' + esc(d.name || d.path || '?') + '</div>';
2104 if (d.language) html += '<span class="tag perm">' + esc(d.language) + '</span>';
2105 return html;
2106 }
2107 function renderImage(d) {
2108 let html = '<div class="meta-type">image</div>';
2109 if (d.url) html += '<img src="' + esc(d.url) + '" alt="' + esc(d.alt || '') + '" loading="lazy">';
2110 return html;
2111 }
2112 function renderGeneric(meta) {
2113 return '<div class="meta-type">' + esc(meta.type) + '</div><pre>' + esc(JSON.stringify(meta.data, null, 2)) + '</pre>';
2114 }
2115
2116 async function sendMsg() {
2117 if (!chatChannel) return;
2118 const input = document.getElementById('chat-text-input');
2119 const nick = document.getElementById('chat-identity').value.trim() || 'web';
2120
--- internal/bots/bridge/bridge.go
+++ internal/bots/bridge/bridge.go
@@ -5,10 +5,11 @@
55
// to post messages back into IRC.
66
package bridge
77
88
import (
99
"context"
10
+ "encoding/json"
1011
"fmt"
1112
"log/slog"
1213
"net"
1314
"strconv"
1415
"strings"
@@ -19,17 +20,25 @@
1920
"github.com/lrstanley/girc"
2021
)
2122
2223
const botNick = "bridge"
2324
const defaultWebUserTTL = 5 * time.Minute
25
+
26
+// Meta is optional structured metadata attached to a bridge message.
27
+// IRC sees only the plain text; the web UI uses Meta for rich rendering.
28
+type Meta struct {
29
+ Type string `json:"type"`
30
+ Data json.RawMessage `json:"data"`
31
+}
2432
2533
// Message is a single IRC message captured by the bridge.
2634
type Message struct {
2735
At time.Time `json:"at"`
2836
Channel string `json:"channel"`
2937
Nick string `json:"nick"`
3038
Text string `json:"text"`
39
+ Meta *Meta `json:"meta,omitempty"`
3140
}
3241
3342
// ringBuf is a fixed-capacity circular buffer of Messages.
3443
type ringBuf struct {
3544
msgs []Message
@@ -323,35 +332,40 @@
323332
324333
// Send sends a message to channel. The message is attributed to senderNick
325334
// via a visible prefix: "[senderNick] text". The sent message is also pushed
326335
// directly into the buffer since IRC servers don't echo messages back to sender.
327336
func (b *Bot) Send(ctx context.Context, channel, text, senderNick string) error {
337
+ return b.SendWithMeta(ctx, channel, text, senderNick, nil)
338
+}
339
+
340
+// SendWithMeta sends a message to channel with optional structured metadata.
341
+// IRC receives only the plain text; SSE subscribers receive the full message
342
+// including meta for rich rendering in the web UI.
343
+func (b *Bot) SendWithMeta(ctx context.Context, channel, text, senderNick string, meta *Meta) error {
328344
if b.client == nil {
329345
return fmt.Errorf("bridge: not connected")
330346
}
331347
ircText := text
332348
if senderNick != "" {
333349
ircText = "[" + senderNick + "] " + text
334350
}
335351
b.client.Cmd.Message(channel, ircText)
336352
337
- // Track web sender as active in this channel.
338353
if senderNick != "" {
339354
b.TouchUser(channel, senderNick)
340355
}
341356
342
- // Buffer the outgoing message immediately (server won't echo it back).
343
- // Use senderNick so the web UI shows who actually sent it.
344357
displayNick := b.nick
345358
if senderNick != "" {
346359
displayNick = senderNick
347360
}
348361
b.dispatch(Message{
349362
At: time.Now(),
350363
Channel: channel,
351364
Nick: displayNick,
352365
Text: text,
366
+ Meta: meta,
353367
})
354368
return nil
355369
}
356370
357371
// TouchUser marks a bridge/web nick as active in the given channel without
358372
--- internal/bots/bridge/bridge.go
+++ internal/bots/bridge/bridge.go
@@ -5,10 +5,11 @@
5 // to post messages back into IRC.
6 package bridge
7
8 import (
9 "context"
 
10 "fmt"
11 "log/slog"
12 "net"
13 "strconv"
14 "strings"
@@ -19,17 +20,25 @@
19 "github.com/lrstanley/girc"
20 )
21
22 const botNick = "bridge"
23 const defaultWebUserTTL = 5 * time.Minute
 
 
 
 
 
 
 
24
25 // Message is a single IRC message captured by the bridge.
26 type Message struct {
27 At time.Time `json:"at"`
28 Channel string `json:"channel"`
29 Nick string `json:"nick"`
30 Text string `json:"text"`
 
31 }
32
33 // ringBuf is a fixed-capacity circular buffer of Messages.
34 type ringBuf struct {
35 msgs []Message
@@ -323,35 +332,40 @@
323
324 // Send sends a message to channel. The message is attributed to senderNick
325 // via a visible prefix: "[senderNick] text". The sent message is also pushed
326 // directly into the buffer since IRC servers don't echo messages back to sender.
327 func (b *Bot) Send(ctx context.Context, channel, text, senderNick string) error {
 
 
 
 
 
 
 
328 if b.client == nil {
329 return fmt.Errorf("bridge: not connected")
330 }
331 ircText := text
332 if senderNick != "" {
333 ircText = "[" + senderNick + "] " + text
334 }
335 b.client.Cmd.Message(channel, ircText)
336
337 // Track web sender as active in this channel.
338 if senderNick != "" {
339 b.TouchUser(channel, senderNick)
340 }
341
342 // Buffer the outgoing message immediately (server won't echo it back).
343 // Use senderNick so the web UI shows who actually sent it.
344 displayNick := b.nick
345 if senderNick != "" {
346 displayNick = senderNick
347 }
348 b.dispatch(Message{
349 At: time.Now(),
350 Channel: channel,
351 Nick: displayNick,
352 Text: text,
 
353 })
354 return nil
355 }
356
357 // TouchUser marks a bridge/web nick as active in the given channel without
358
--- internal/bots/bridge/bridge.go
+++ internal/bots/bridge/bridge.go
@@ -5,10 +5,11 @@
5 // to post messages back into IRC.
6 package bridge
7
8 import (
9 "context"
10 "encoding/json"
11 "fmt"
12 "log/slog"
13 "net"
14 "strconv"
15 "strings"
@@ -19,17 +20,25 @@
20 "github.com/lrstanley/girc"
21 )
22
23 const botNick = "bridge"
24 const defaultWebUserTTL = 5 * time.Minute
25
26 // Meta is optional structured metadata attached to a bridge message.
27 // IRC sees only the plain text; the web UI uses Meta for rich rendering.
28 type Meta struct {
29 Type string `json:"type"`
30 Data json.RawMessage `json:"data"`
31 }
32
33 // Message is a single IRC message captured by the bridge.
34 type Message struct {
35 At time.Time `json:"at"`
36 Channel string `json:"channel"`
37 Nick string `json:"nick"`
38 Text string `json:"text"`
39 Meta *Meta `json:"meta,omitempty"`
40 }
41
42 // ringBuf is a fixed-capacity circular buffer of Messages.
43 type ringBuf struct {
44 msgs []Message
@@ -323,35 +332,40 @@
332
333 // Send sends a message to channel. The message is attributed to senderNick
334 // via a visible prefix: "[senderNick] text". The sent message is also pushed
335 // directly into the buffer since IRC servers don't echo messages back to sender.
336 func (b *Bot) Send(ctx context.Context, channel, text, senderNick string) error {
337 return b.SendWithMeta(ctx, channel, text, senderNick, nil)
338 }
339
340 // SendWithMeta sends a message to channel with optional structured metadata.
341 // IRC receives only the plain text; SSE subscribers receive the full message
342 // including meta for rich rendering in the web UI.
343 func (b *Bot) SendWithMeta(ctx context.Context, channel, text, senderNick string, meta *Meta) error {
344 if b.client == nil {
345 return fmt.Errorf("bridge: not connected")
346 }
347 ircText := text
348 if senderNick != "" {
349 ircText = "[" + senderNick + "] " + text
350 }
351 b.client.Cmd.Message(channel, ircText)
352
 
353 if senderNick != "" {
354 b.TouchUser(channel, senderNick)
355 }
356
 
 
357 displayNick := b.nick
358 if senderNick != "" {
359 displayNick = senderNick
360 }
361 b.dispatch(Message{
362 At: time.Now(),
363 Channel: channel,
364 Nick: displayNick,
365 Text: text,
366 Meta: meta,
367 })
368 return nil
369 }
370
371 // TouchUser marks a bridge/web nick as active in the given channel without
372
--- internal/bots/bridge/bridge.go
+++ internal/bots/bridge/bridge.go
@@ -5,10 +5,11 @@
55
// to post messages back into IRC.
66
package bridge
77
88
import (
99
"context"
10
+ "encoding/json"
1011
"fmt"
1112
"log/slog"
1213
"net"
1314
"strconv"
1415
"strings"
@@ -19,17 +20,25 @@
1920
"github.com/lrstanley/girc"
2021
)
2122
2223
const botNick = "bridge"
2324
const defaultWebUserTTL = 5 * time.Minute
25
+
26
+// Meta is optional structured metadata attached to a bridge message.
27
+// IRC sees only the plain text; the web UI uses Meta for rich rendering.
28
+type Meta struct {
29
+ Type string `json:"type"`
30
+ Data json.RawMessage `json:"data"`
31
+}
2432
2533
// Message is a single IRC message captured by the bridge.
2634
type Message struct {
2735
At time.Time `json:"at"`
2836
Channel string `json:"channel"`
2937
Nick string `json:"nick"`
3038
Text string `json:"text"`
39
+ Meta *Meta `json:"meta,omitempty"`
3140
}
3241
3342
// ringBuf is a fixed-capacity circular buffer of Messages.
3443
type ringBuf struct {
3544
msgs []Message
@@ -323,35 +332,40 @@
323332
324333
// Send sends a message to channel. The message is attributed to senderNick
325334
// via a visible prefix: "[senderNick] text". The sent message is also pushed
326335
// directly into the buffer since IRC servers don't echo messages back to sender.
327336
func (b *Bot) Send(ctx context.Context, channel, text, senderNick string) error {
337
+ return b.SendWithMeta(ctx, channel, text, senderNick, nil)
338
+}
339
+
340
+// SendWithMeta sends a message to channel with optional structured metadata.
341
+// IRC receives only the plain text; SSE subscribers receive the full message
342
+// including meta for rich rendering in the web UI.
343
+func (b *Bot) SendWithMeta(ctx context.Context, channel, text, senderNick string, meta *Meta) error {
328344
if b.client == nil {
329345
return fmt.Errorf("bridge: not connected")
330346
}
331347
ircText := text
332348
if senderNick != "" {
333349
ircText = "[" + senderNick + "] " + text
334350
}
335351
b.client.Cmd.Message(channel, ircText)
336352
337
- // Track web sender as active in this channel.
338353
if senderNick != "" {
339354
b.TouchUser(channel, senderNick)
340355
}
341356
342
- // Buffer the outgoing message immediately (server won't echo it back).
343
- // Use senderNick so the web UI shows who actually sent it.
344357
displayNick := b.nick
345358
if senderNick != "" {
346359
displayNick = senderNick
347360
}
348361
b.dispatch(Message{
349362
At: time.Now(),
350363
Channel: channel,
351364
Nick: displayNick,
352365
Text: text,
366
+ Meta: meta,
353367
})
354368
return nil
355369
}
356370
357371
// TouchUser marks a bridge/web nick as active in the given channel without
358372
--- internal/bots/bridge/bridge.go
+++ internal/bots/bridge/bridge.go
@@ -5,10 +5,11 @@
5 // to post messages back into IRC.
6 package bridge
7
8 import (
9 "context"
 
10 "fmt"
11 "log/slog"
12 "net"
13 "strconv"
14 "strings"
@@ -19,17 +20,25 @@
19 "github.com/lrstanley/girc"
20 )
21
22 const botNick = "bridge"
23 const defaultWebUserTTL = 5 * time.Minute
 
 
 
 
 
 
 
24
25 // Message is a single IRC message captured by the bridge.
26 type Message struct {
27 At time.Time `json:"at"`
28 Channel string `json:"channel"`
29 Nick string `json:"nick"`
30 Text string `json:"text"`
 
31 }
32
33 // ringBuf is a fixed-capacity circular buffer of Messages.
34 type ringBuf struct {
35 msgs []Message
@@ -323,35 +332,40 @@
323
324 // Send sends a message to channel. The message is attributed to senderNick
325 // via a visible prefix: "[senderNick] text". The sent message is also pushed
326 // directly into the buffer since IRC servers don't echo messages back to sender.
327 func (b *Bot) Send(ctx context.Context, channel, text, senderNick string) error {
 
 
 
 
 
 
 
328 if b.client == nil {
329 return fmt.Errorf("bridge: not connected")
330 }
331 ircText := text
332 if senderNick != "" {
333 ircText = "[" + senderNick + "] " + text
334 }
335 b.client.Cmd.Message(channel, ircText)
336
337 // Track web sender as active in this channel.
338 if senderNick != "" {
339 b.TouchUser(channel, senderNick)
340 }
341
342 // Buffer the outgoing message immediately (server won't echo it back).
343 // Use senderNick so the web UI shows who actually sent it.
344 displayNick := b.nick
345 if senderNick != "" {
346 displayNick = senderNick
347 }
348 b.dispatch(Message{
349 At: time.Now(),
350 Channel: channel,
351 Nick: displayNick,
352 Text: text,
 
353 })
354 return nil
355 }
356
357 // TouchUser marks a bridge/web nick as active in the given channel without
358
--- internal/bots/bridge/bridge.go
+++ internal/bots/bridge/bridge.go
@@ -5,10 +5,11 @@
5 // to post messages back into IRC.
6 package bridge
7
8 import (
9 "context"
10 "encoding/json"
11 "fmt"
12 "log/slog"
13 "net"
14 "strconv"
15 "strings"
@@ -19,17 +20,25 @@
20 "github.com/lrstanley/girc"
21 )
22
23 const botNick = "bridge"
24 const defaultWebUserTTL = 5 * time.Minute
25
26 // Meta is optional structured metadata attached to a bridge message.
27 // IRC sees only the plain text; the web UI uses Meta for rich rendering.
28 type Meta struct {
29 Type string `json:"type"`
30 Data json.RawMessage `json:"data"`
31 }
32
33 // Message is a single IRC message captured by the bridge.
34 type Message struct {
35 At time.Time `json:"at"`
36 Channel string `json:"channel"`
37 Nick string `json:"nick"`
38 Text string `json:"text"`
39 Meta *Meta `json:"meta,omitempty"`
40 }
41
42 // ringBuf is a fixed-capacity circular buffer of Messages.
43 type ringBuf struct {
44 msgs []Message
@@ -323,35 +332,40 @@
332
333 // Send sends a message to channel. The message is attributed to senderNick
334 // via a visible prefix: "[senderNick] text". The sent message is also pushed
335 // directly into the buffer since IRC servers don't echo messages back to sender.
336 func (b *Bot) Send(ctx context.Context, channel, text, senderNick string) error {
337 return b.SendWithMeta(ctx, channel, text, senderNick, nil)
338 }
339
340 // SendWithMeta sends a message to channel with optional structured metadata.
341 // IRC receives only the plain text; SSE subscribers receive the full message
342 // including meta for rich rendering in the web UI.
343 func (b *Bot) SendWithMeta(ctx context.Context, channel, text, senderNick string, meta *Meta) error {
344 if b.client == nil {
345 return fmt.Errorf("bridge: not connected")
346 }
347 ircText := text
348 if senderNick != "" {
349 ircText = "[" + senderNick + "] " + text
350 }
351 b.client.Cmd.Message(channel, ircText)
352
 
353 if senderNick != "" {
354 b.TouchUser(channel, senderNick)
355 }
356
 
 
357 displayNick := b.nick
358 if senderNick != "" {
359 displayNick = senderNick
360 }
361 b.dispatch(Message{
362 At: time.Now(),
363 Channel: channel,
364 Nick: displayNick,
365 Text: text,
366 Meta: meta,
367 })
368 return nil
369 }
370
371 // TouchUser marks a bridge/web nick as active in the given channel without
372
--- pkg/sessionrelay/http.go
+++ pkg/sessionrelay/http.go
@@ -91,27 +91,39 @@
9191
}
9292
return nil
9393
}
9494
9595
func (c *httpConnector) Post(ctx context.Context, text string) error {
96
+ return c.PostWithMeta(ctx, text, nil)
97
+}
98
+
99
+func (c *httpConnector) PostTo(ctx context.Context, channel, text string) error {
100
+ return c.PostToWithMeta(ctx, channel, text, nil)
101
+}
102
+
103
+func (c *httpConnector) PostWithMeta(ctx context.Context, text string, meta json.RawMessage) error {
96104
for _, channel := range c.Channels() {
97
- if err := c.PostTo(ctx, channel, text); err != nil {
105
+ if err := c.PostToWithMeta(ctx, channel, text, meta); err != nil {
98106
return err
99107
}
100108
}
101109
return nil
102110
}
103111
104
-func (c *httpConnector) PostTo(ctx context.Context, channel, text string) error {
112
+func (c *httpConnector) PostToWithMeta(ctx context.Context, channel, text string, meta json.RawMessage) error {
105113
channel = channelSlug(channel)
106114
if channel == "" {
107115
return fmt.Errorf("sessionrelay: post channel is required")
108116
}
109
- return c.postJSON(ctx, "/v1/channels/"+channel+"/messages", map[string]string{
117
+ body := map[string]any{
110118
"nick": c.nick,
111119
"text": text,
112
- })
120
+ }
121
+ if len(meta) > 0 {
122
+ body["meta"] = json.RawMessage(meta)
123
+ }
124
+ return c.postJSON(ctx, "/v1/channels/"+channel+"/messages", body)
113125
}
114126
115127
func (c *httpConnector) MessagesSince(ctx context.Context, since time.Time) ([]Message, error) {
116128
out := make([]Message, 0, 32)
117129
for _, channel := range c.Channels() {
118130
--- pkg/sessionrelay/http.go
+++ pkg/sessionrelay/http.go
@@ -91,27 +91,39 @@
91 }
92 return nil
93 }
94
95 func (c *httpConnector) Post(ctx context.Context, text string) error {
 
 
 
 
 
 
 
 
96 for _, channel := range c.Channels() {
97 if err := c.PostTo(ctx, channel, text); err != nil {
98 return err
99 }
100 }
101 return nil
102 }
103
104 func (c *httpConnector) PostTo(ctx context.Context, channel, text string) error {
105 channel = channelSlug(channel)
106 if channel == "" {
107 return fmt.Errorf("sessionrelay: post channel is required")
108 }
109 return c.postJSON(ctx, "/v1/channels/"+channel+"/messages", map[string]string{
110 "nick": c.nick,
111 "text": text,
112 })
 
 
 
 
113 }
114
115 func (c *httpConnector) MessagesSince(ctx context.Context, since time.Time) ([]Message, error) {
116 out := make([]Message, 0, 32)
117 for _, channel := range c.Channels() {
118
--- pkg/sessionrelay/http.go
+++ pkg/sessionrelay/http.go
@@ -91,27 +91,39 @@
91 }
92 return nil
93 }
94
95 func (c *httpConnector) Post(ctx context.Context, text string) error {
96 return c.PostWithMeta(ctx, text, nil)
97 }
98
99 func (c *httpConnector) PostTo(ctx context.Context, channel, text string) error {
100 return c.PostToWithMeta(ctx, channel, text, nil)
101 }
102
103 func (c *httpConnector) PostWithMeta(ctx context.Context, text string, meta json.RawMessage) error {
104 for _, channel := range c.Channels() {
105 if err := c.PostToWithMeta(ctx, channel, text, meta); err != nil {
106 return err
107 }
108 }
109 return nil
110 }
111
112 func (c *httpConnector) PostToWithMeta(ctx context.Context, channel, text string, meta json.RawMessage) error {
113 channel = channelSlug(channel)
114 if channel == "" {
115 return fmt.Errorf("sessionrelay: post channel is required")
116 }
117 body := map[string]any{
118 "nick": c.nick,
119 "text": text,
120 }
121 if len(meta) > 0 {
122 body["meta"] = json.RawMessage(meta)
123 }
124 return c.postJSON(ctx, "/v1/channels/"+channel+"/messages", body)
125 }
126
127 func (c *httpConnector) MessagesSince(ctx context.Context, since time.Time) ([]Message, error) {
128 out := make([]Message, 0, 32)
129 for _, channel := range c.Channels() {
130
--- pkg/sessionrelay/irc.go
+++ pkg/sessionrelay/irc.go
@@ -214,10 +214,19 @@
214214
})
215215
}
216216
}
217217
218218
func (c *ircConnector) Post(_ context.Context, text string) error {
219
+ return c.PostWithMeta(context.Background(), text, nil)
220
+}
221
+
222
+func (c *ircConnector) PostTo(_ context.Context, channel, text string) error {
223
+ return c.PostToWithMeta(context.Background(), channel, text, nil)
224
+}
225
+
226
+// PostWithMeta sends text to all channels. Meta is ignored — IRC is text-only.
227
+func (c *ircConnector) PostWithMeta(_ context.Context, text string, _ json.RawMessage) error {
219228
c.mu.RLock()
220229
client := c.client
221230
c.mu.RUnlock()
222231
if client == nil {
223232
return fmt.Errorf("sessionrelay: irc client not connected")
@@ -226,11 +235,12 @@
226235
client.Cmd.Message(channel, text)
227236
}
228237
return nil
229238
}
230239
231
-func (c *ircConnector) PostTo(_ context.Context, channel, text string) error {
240
+// PostToWithMeta sends text to a specific channel. Meta is ignored — IRC is text-only.
241
+func (c *ircConnector) PostToWithMeta(_ context.Context, channel, text string, _ json.RawMessage) error {
232242
c.mu.RLock()
233243
client := c.client
234244
c.mu.RUnlock()
235245
if client == nil {
236246
return fmt.Errorf("sessionrelay: irc client not connected")
237247
--- pkg/sessionrelay/irc.go
+++ pkg/sessionrelay/irc.go
@@ -214,10 +214,19 @@
214 })
215 }
216 }
217
218 func (c *ircConnector) Post(_ context.Context, text string) error {
 
 
 
 
 
 
 
 
 
219 c.mu.RLock()
220 client := c.client
221 c.mu.RUnlock()
222 if client == nil {
223 return fmt.Errorf("sessionrelay: irc client not connected")
@@ -226,11 +235,12 @@
226 client.Cmd.Message(channel, text)
227 }
228 return nil
229 }
230
231 func (c *ircConnector) PostTo(_ context.Context, channel, text string) error {
 
232 c.mu.RLock()
233 client := c.client
234 c.mu.RUnlock()
235 if client == nil {
236 return fmt.Errorf("sessionrelay: irc client not connected")
237
--- pkg/sessionrelay/irc.go
+++ pkg/sessionrelay/irc.go
@@ -214,10 +214,19 @@
214 })
215 }
216 }
217
218 func (c *ircConnector) Post(_ context.Context, text string) error {
219 return c.PostWithMeta(context.Background(), text, nil)
220 }
221
222 func (c *ircConnector) PostTo(_ context.Context, channel, text string) error {
223 return c.PostToWithMeta(context.Background(), channel, text, nil)
224 }
225
226 // PostWithMeta sends text to all channels. Meta is ignored — IRC is text-only.
227 func (c *ircConnector) PostWithMeta(_ context.Context, text string, _ json.RawMessage) error {
228 c.mu.RLock()
229 client := c.client
230 c.mu.RUnlock()
231 if client == nil {
232 return fmt.Errorf("sessionrelay: irc client not connected")
@@ -226,11 +235,12 @@
235 client.Cmd.Message(channel, text)
236 }
237 return nil
238 }
239
240 // PostToWithMeta sends text to a specific channel. Meta is ignored — IRC is text-only.
241 func (c *ircConnector) PostToWithMeta(_ context.Context, channel, text string, _ json.RawMessage) error {
242 c.mu.RLock()
243 client := c.client
244 c.mu.RUnlock()
245 if client == nil {
246 return fmt.Errorf("sessionrelay: irc client not connected")
247
--- pkg/sessionrelay/sessionrelay.go
+++ pkg/sessionrelay/sessionrelay.go
@@ -1,9 +1,10 @@
11
package sessionrelay
22
33
import (
44
"context"
5
+ "encoding/json"
56
"fmt"
67
"net/http"
78
"strings"
89
"time"
910
)
@@ -47,10 +48,12 @@
4748
4849
type Connector interface {
4950
Connect(ctx context.Context) error
5051
Post(ctx context.Context, text string) error
5152
PostTo(ctx context.Context, channel, text string) error
53
+ PostWithMeta(ctx context.Context, text string, meta json.RawMessage) error
54
+ PostToWithMeta(ctx context.Context, channel, text string, meta json.RawMessage) error
5255
MessagesSince(ctx context.Context, since time.Time) ([]Message, error)
5356
Touch(ctx context.Context) error
5457
JoinChannel(ctx context.Context, channel string) error
5558
PartChannel(ctx context.Context, channel string) error
5659
Channels() []string
5760
--- pkg/sessionrelay/sessionrelay.go
+++ pkg/sessionrelay/sessionrelay.go
@@ -1,9 +1,10 @@
1 package sessionrelay
2
3 import (
4 "context"
 
5 "fmt"
6 "net/http"
7 "strings"
8 "time"
9 )
@@ -47,10 +48,12 @@
47
48 type Connector interface {
49 Connect(ctx context.Context) error
50 Post(ctx context.Context, text string) error
51 PostTo(ctx context.Context, channel, text string) error
 
 
52 MessagesSince(ctx context.Context, since time.Time) ([]Message, error)
53 Touch(ctx context.Context) error
54 JoinChannel(ctx context.Context, channel string) error
55 PartChannel(ctx context.Context, channel string) error
56 Channels() []string
57
--- pkg/sessionrelay/sessionrelay.go
+++ pkg/sessionrelay/sessionrelay.go
@@ -1,9 +1,10 @@
1 package sessionrelay
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "net/http"
8 "strings"
9 "time"
10 )
@@ -47,10 +48,12 @@
48
49 type Connector interface {
50 Connect(ctx context.Context) error
51 Post(ctx context.Context, text string) error
52 PostTo(ctx context.Context, channel, text string) error
53 PostWithMeta(ctx context.Context, text string, meta json.RawMessage) error
54 PostToWithMeta(ctx context.Context, channel, text string, meta json.RawMessage) error
55 MessagesSince(ctx context.Context, since time.Time) ([]Message, error)
56 Touch(ctx context.Context) error
57 JoinChannel(ctx context.Context, channel string) error
58 PartChannel(ctx context.Context, channel string) error
59 Channels() []string
60

Keyboard Shortcuts

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