diff --git a/cmd/pad/cmd_session.go b/cmd/pad/cmd_session.go index 658e02f2c..bbeb460e2 100644 --- a/cmd/pad/cmd_session.go +++ b/cmd/pad/cmd_session.go @@ -340,8 +340,9 @@ func sessionPruneCmd() *cobra.Command { Use: "prune", Short: "Remove dead sessions' records from the local session registry", Example: ` pad session prune --older-than 72h`, - Long: `Deletes registry records whose session is dead. Never deletes a record -it can see is alive. + Long: `Deletes registry records whose session is dead, with each one's chat-seen +log (~/.pad/chat-seen/.log and .log.1). Never deletes a record it can +see is alive. Records whose liveness is unknown (this platform cannot probe the owner, or the file is malformed) are kept unless --older-than is given, in which diff --git a/cmd/pad/cmd_watch.go b/cmd/pad/cmd_watch.go index 4db69854f..6b359f237 100644 --- a/cmd/pad/cmd_watch.go +++ b/cmd/pad/cmd_watch.go @@ -260,6 +260,15 @@ pad watch --stream --for-session with at most three attempts. Success is silent; a final failure writes one stderr line and delivery continues. + Every chat line is also appended, byte for byte, to + ~/.pad/chat-seen/.log (0600), so the person at the agent's + terminal can see it: the Claude Code plugin shows it above the prompt, + and any other harness can tail the file. The pid is PAD_SESSION_PID, + else CLAUDE_PID; with neither set, or with one that is not a pid, + there is no log, and the first chat line writes one stderr line saying + so. The log holds 256 KiB, then moves + to .log.1; pad session prune removes both with the session. + Silent on startup in three cases, and silence does not distinguish them: no workspace linked (retries hourly), padd unreachable (backoff from 5s, capped at 5min), and the server refusing the @@ -488,6 +497,11 @@ func runWatchMonitor(ctx context.Context) error { var lastEventID string attempt := 0 chat := newMonitorChat(nil, seenChat) + // TASK-4071: chat lines also go to ~/.pad/chat-seen/.log + // for the human at this agent's terminal. Disabled, with one stderr + // line, when there is no harness pid to key it by. + owner, ownerErr := cli.CaptureSessionOwner() + chat.seen = cli.OpenChatSeen(owner, ownerErr, os.Stderr) ensureDM := newMonitorEnsureDM(os.Stderr) for { diff --git a/cmd/pad/cmd_watch_chat.go b/cmd/pad/cmd_watch_chat.go index f8cdee856..c39bc59cd 100644 --- a/cmd/pad/cmd_watch_chat.go +++ b/cmd/pad/cmd_watch_chat.go @@ -143,12 +143,26 @@ type monitorChat struct { retryAfter map[int]time.Time identityRetryAfter time.Time summaryRetryAfter time.Time + // seen tees every chat line to the session's chat-seen log so the human + // at the agent's terminal sees it too (TASK-4071). Nil writes nothing. + seen *cli.ChatSeenLog } func newMonitorChat(sink chatSink, printed *recentSet) *monitorChat { return &monitorChat{sink: sink, printed: printed, printedIDs: map[string]bool{}, last: map[int]int64{}, delivered: map[int]int64{}, floorTimeout: monitorChatFloorTimeout, now: time.Now, retryAfter: map[int]time.Time{}} } +// emit prints one chat line to the agent and, once that write succeeded, +// appends the same line to the chat-seen log. Every chat line goes through +// here and nothing else does: item, push and resync lines are not chat. +func (c *monitorChat) emit(w io.Writer, line string) error { + if _, err := fmt.Fprintln(w, line); err != nil { + return err + } + c.seen.Append(line) + return nil +} + func (c *monitorChat) setIdentity(baseURL, userID string) { identity := monitorChatIdentity{baseURL, userID} if identity == c.identity { @@ -194,7 +208,7 @@ func (c *monitorChat) retract(w io.Writer, p watchStreamPayload) bool { if !c.printedIDs[p.MessageID] || c.printed.contains(key) { return true } - if _, err := fmt.Fprintln(w, formatChatLine(monitorChatMessage{ID: p.MessageID, Conversation: p.Conversation, Deleted: true}, false)); err != nil { + if err := c.emit(w, formatChatLine(monitorChatMessage{ID: p.MessageID, Conversation: p.Conversation, Deleted: true}, false)); err != nil { return false // Leave an owed retraction retryable. } c.printedIDs[p.MessageID] = false @@ -291,7 +305,7 @@ func (c *monitorChat) handle(w io.Writer, p watchStreamPayload) { if !cli.ResolveAnnouncedArmed() { return } - if _, err := fmt.Fprintln(w, formatChatLine(m, direction)); err != nil { + if err := c.emit(w, formatChatLine(m, direction)); err != nil { return } c.printed.seen(m.ID) @@ -306,7 +320,7 @@ func (c *monitorChat) handle(w io.Writer, p watchStreamPayload) { highest = m.Seq } if after < p.MessageSeq && len(messages) == 20 && cli.ResolveAnnouncedArmed() { - fmt.Fprintf(w, "up to %d more in C%d: pad chat show C%d --since %d\n", p.MessageSeq-after, p.Conversation, p.Conversation, after) + _ = c.emit(w, fmt.Sprintf("up to %d more in C%d: pad chat show C%d --since %d", p.MessageSeq-after, p.Conversation, p.Conversation, after)) } } @@ -411,7 +425,7 @@ func (c *monitorChat) applySummary(w io.Writer, result monitorChatSummary) bool } summary := result.unread if summary.Total > 0 { - fmt.Fprintf(w, "Pad chat: %d unread messages — use pad chat show to read them.\n", summary.Total) + _ = c.emit(w, fmt.Sprintf("Pad chat: %d unread messages — use pad chat show to read them.", summary.Total)) } return true } diff --git a/cmd/pad/cmd_watch_chat_seen_test.go b/cmd/pad/cmd_watch_chat_seen_test.go new file mode 100644 index 000000000..69dde4ff6 --- /dev/null +++ b/cmd/pad/cmd_watch_chat_seen_test.go @@ -0,0 +1,95 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "strings" + "testing" + + "github.com/PerpetualSoftware/pad/internal/cli" +) + +// TASK-4071: every chat line the monitor prints is teed, byte for byte, to +// the session's chat-seen log, and no other monitor line is. +func TestMonitorTeesEveryChatSiteAndNothingElse(t *testing.T) { + chatSessionEnv(t) + f := &fakeChatSink{total: 2} + for seq := int64(1); seq <= 43; seq++ { + f.messages = append(f.messages, chatMsg(fmt.Sprint(seq), seq)) + } + var warn bytes.Buffer + c := newSeededMonitorChat(f, newRecentSet(256, 10*60*1e9)) + c.seen = cli.OpenChatSeen(cli.SessionOwner{PID: 4071, PIDSource: "PAD_SESSION_PID"}, nil, &warn) + + item := watchStreamPayload{Kind: "item.updated", Workspace: "ws", ItemRef: "TASK-1", Actor: "a", Summary: "ITEM-LINE"} + push := watchStreamPayload{Kind: pushKind, Workspace: "ws", ItemRef: "TASK-2", Actor: "a", Summary: "PUSH-LINE", PushID: "p-4071"} + sse := func(p watchStreamPayload) string { + b, _ := json.Marshal(p) + return "event: notification\ndata: " + string(b) + "\n\n" + } + body := sse(item) + sse(push) + chatSSE("43", 43, false) + "event: sync_required\ndata: {}\n\n" + + var out bytes.Buffer + // stream: unread summary (site 4) at connect and again on resync, the + // first page of messages (site 2) and its overflow pointer (site 3). + c.stream(&out, fakeSSEResponse(body), "") + // retraction (site 1). + c.handle(&out, watchStreamPayload{Conversation: 1, MessageID: "1", MessageSeq: 1, Deleted: true}) + + stdout := out.String() + for _, want := range []string{"unread messages", "fetched 1\n", "more in C1", "retracted", "ITEM-LINE", "PUSH-LINE", resyncMonitorLine} { + if !strings.Contains(stdout, want) { + t.Fatalf("setup: stdout lacks %q:\n%s", want, stdout) + } + } + var chatOnly strings.Builder + for _, line := range strings.SplitAfter(stdout, "\n") { + if line == "" || strings.Contains(line, "ITEM-LINE") || strings.Contains(line, "PUSH-LINE") || line == resyncMonitorLine+"\n" { + continue + } + chatOnly.WriteString(line) + } + got, err := os.ReadFile(c.seen.Path()) + if err != nil { + t.Fatal(err) + } + if string(got) != chatOnly.String() { + t.Fatalf("chat-seen log is not stdout's chat lines.\nlog:\n%s\nwant:\n%s", got, chatOnly.String()) + } + if warn.Len() != 0 { + t.Fatal(warn.String()) + } +} + +// A line the agent did not get is not shown to the human either. +func TestMonitorTeesOnlyAfterStdoutWrite(t *testing.T) { + chatSessionEnv(t) + f := &fakeChatSink{messages: []monitorChatMessage{chatMsg("a", 1)}} + var warn bytes.Buffer + c := newSeededMonitorChat(f, newRecentSet(256, 10*60*1e9)) + c.seen = cli.OpenChatSeen(cli.SessionOwner{PID: 4072, PIDSource: "PAD_SESSION_PID"}, nil, &warn) + c.handle(failChatWriter{}, watchStreamPayload{Conversation: 1, MessageID: "a", MessageSeq: 1}) + if _, err := os.Stat(c.seen.Path()); !os.IsNotExist(err) { + t.Fatalf("a failed stdout write must not reach the log; stat err = %v", err) + } +} + +// A broken log never costs the agent its line. +func TestMonitorStdoutSurvivesBrokenChatSeen(t *testing.T) { + chatSessionEnv(t) + f := &fakeChatSink{messages: []monitorChatMessage{chatMsg("a", 1), chatMsg("b", 2)}} + path, _ := cli.ChatSeenPath(4073) + if err := os.MkdirAll(path, 0700); err != nil { // a directory where the log goes + t.Fatal(err) + } + var warn bytes.Buffer + c := newSeededMonitorChat(f, newRecentSet(256, 10*60*1e9)) + c.seen = cli.OpenChatSeen(cli.SessionOwner{PID: 4073, PIDSource: "PAD_SESSION_PID"}, nil, &warn) + var out bytes.Buffer + c.handle(&out, watchStreamPayload{Conversation: 1, MessageID: "b", MessageSeq: 2}) + if strings.Count(out.String(), "fetched ") != 2 || strings.Count(warn.String(), "\n") != 1 { + t.Fatalf("stdout %q warn %q", out.String(), warn.String()) + } +} diff --git a/internal/cli/chat_seen.go b/internal/cli/chat_seen.go new file mode 100644 index 000000000..67b016f20 --- /dev/null +++ b/internal/cli/chat_seen.go @@ -0,0 +1,238 @@ +package cli + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "sync" +) + +// The chat-seen log (TASK-4071) is how a chat line delivered to an agent +// also reaches the human at that agent's terminal. `pad watch --stream +// --for-session` appends every chat line it prints to stdout, byte for byte, +// to ~/.pad/chat-seen/.log; the Claude Code plugin tails it +// into a band above the prompt, and any other harness can `tail -F` it. +// +// It is keyed by the HARNESS pid (SessionOwner.PID), the one identity that +// both writers (pad watch, from PAD_SESSION_PID or CLAUDE_PID) and the +// plugin's reader (its sessionPid()) know, and that survives a /clear. +// +// It is a display cue, never a record: the agent's stdout is the record. +// Rotation can therefore lose display only. A reader that polls between two +// rotations misses the lines that moved into .log.1, and nothing +// catches them up (TASK-4071 C3, lead ruling day 35). + +// ChatSeenMaxBytes caps one generation of a session's chat-seen log. On the +// write that would pass it, the log moves to .log.1 (replacing an older +// one) and a fresh log starts, so a session holds at most two generations. +const ChatSeenMaxBytes = 256 << 10 + +// ensurePrivateDir creates dir at 0700, and tightens it to 0700 when it +// already existed looser: MkdirAll applies the mode only on creation. +func ensurePrivateDir(dir string) error { + if err := os.MkdirAll(dir, 0700); err != nil { + return err + } + if info, err := os.Stat(dir); err == nil && info.Mode().Perm() != 0700 { + _ = os.Chmod(dir, 0700) + } + return nil +} + +// chatSeenDirPath is ~/.pad/chat-seen, without creating it. +func chatSeenDirPath() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("get home directory: %w", err) + } + return filepath.Join(homeDir, ".pad", "chat-seen"), nil +} + +// ChatSeenPath is the log for one harness pid. +func ChatSeenPath(pid int) (string, error) { + dir, err := chatSeenDirPath() + if err != nil { + return "", err + } + return filepath.Join(dir, strconv.Itoa(pid)+".log"), nil +} + +// removeChatSeen deletes both generations of a pid's log. It never creates +// the directory, and a missing file is not an error. +func removeChatSeen(pid int) error { + if pid <= 0 { + return nil + } + path, err := ChatSeenPath(pid) + if err != nil { + return err + } + for _, p := range []string{path, path + ".1"} { + if err := os.Remove(p); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + } + return nil +} + +// ChatSeenLog appends chat lines to one session's log. A nil *ChatSeenLog is +// valid and writes nothing. Any failure is reported once on warn and turns +// the log off for the rest of the process; it never affects stdout. +type ChatSeenLog struct { + mu sync.Mutex + path string + max int64 + warn io.Writer + f *os.File + size int64 + disabled bool + // off is why this process keeps no log at all. It is reported on the + // first line that would have been written, not at start: the monitor + // prints nothing until it has something to deliver (DOC-2479). + off string +} + +// OpenChatSeen decides whether this process keeps a chat-seen log. It +// writes nothing yet: the file is created on the first line, so a session +// that never receives chat leaves no file, and a log that is off says so on +// that first line too. +// +// A pid that came from os.Getpid() (PIDSource "self", neither +// PAD_SESSION_PID nor CLAUDE_PID set) disables it (TASK-4071 C2): the log +// would be keyed by pad watch's own pid, which no reader looks for and no +// session record names, so nothing would ever read or prune it. +func OpenChatSeen(owner SessionOwner, ownerErr error, warn io.Writer) *ChatSeenLog { + l := &ChatSeenLog{max: ChatSeenMaxBytes, warn: warn} + switch { + case ownerErr != nil: + l.off = ownerErr.Error() + case owner.PIDSource == "self" || owner.PID <= 0: + l.off = "no harness pid (set PAD_SESSION_PID to the agent harness's pid)" + default: + path, err := ChatSeenPath(owner.PID) + if err != nil { + l.off = err.Error() + } + l.path = path + } + return l +} + +// Path is the log's path, or "" when there is none. +func (l *ChatSeenLog) Path() string { + if l == nil || l.off != "" { + return "" + } + return l.path +} + +// Append writes line plus a newline: the same bytes the caller printed. +func (l *ChatSeenLog) Append(line string) { + if l == nil { + return + } + l.mu.Lock() + defer l.mu.Unlock() + if l.disabled { + return + } + if l.off != "" { + l.disabled = true + fmt.Fprintf(l.warn, "Pad chat: not showing chat lines to the human: %s.\n", l.off) + return + } + if err := l.append(line + "\n"); err != nil { + l.disabled = true + if l.f != nil { + _ = l.f.Close() + l.f = nil + } + fmt.Fprintf(l.warn, "Pad chat: stopped showing chat lines to the human: %s\n", err) + } +} + +func (l *ChatSeenLog) append(data string) error { + // A log removed under us (pad session prune --older-than on a record + // whose liveness was unknown) would otherwise take every later line + // into an unlinked file nobody can read: reopen at the path instead. + if l.f != nil && !l.stillAtPath() { + _ = l.f.Close() + l.f = nil + } + if l.f == nil { + if err := l.open(); err != nil { + return err + } + } + n := int64(len(data)) + if l.size > 0 && l.size+n > l.max { + // C3: lines a reader has not polled yet move to .1 and are not + // shown. The agent's stdout already carried them. + _ = l.f.Close() + l.f = nil + // Rename does not replace an existing file on Windows. + if err := os.Remove(l.path + ".1"); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("rotate %s: %w", l.path, err) + } + if err := os.Rename(l.path, l.path+".1"); err != nil { + return fmt.Errorf("rotate %s: %w", l.path, err) + } + if err := l.open(); err != nil { + return err + } + } + w, err := l.f.WriteString(data) + l.size += int64(w) + if err != nil { + return fmt.Errorf("write %s: %w", l.path, err) + } + return nil +} + +// stillAtPath reports whether the open file is still the one at l.path. +func (l *ChatSeenLog) stillAtPath() bool { + open, err := l.f.Stat() + if err != nil { + return false + } + at, err := os.Lstat(l.path) + return err == nil && os.SameFile(open, at) +} + +// open opens (creating) the log 0600 in a 0700 directory. It refuses a +// symlinked directory, a symlink at the log's path and anything but a +// regular file, and tightens a looser file. +func (l *ChatSeenLog) open() error { + dir := filepath.Dir(l.path) + // ensurePrivateDir follows a symlink, and would chmod its target. + if info, err := os.Lstat(dir); err == nil && info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("%s is a symlink", dir) + } + if err := ensurePrivateDir(dir); err != nil { + return fmt.Errorf("create %s: %w", dir, err) + } + f, err := os.OpenFile(l.path, chatSeenOpenFlags, 0600) + if err != nil { + return fmt.Errorf("open %s: %w", l.path, err) + } + info, err := f.Stat() + if err != nil { + _ = f.Close() + return fmt.Errorf("stat %s: %w", l.path, err) + } + if !info.Mode().IsRegular() { + _ = f.Close() + return fmt.Errorf("%s is not a regular file", l.path) + } + if info.Mode().Perm() != 0600 { + if err := f.Chmod(0600); err != nil { + _ = f.Close() + return fmt.Errorf("chmod %s: %w", l.path, err) + } + } + l.f, l.size = f, info.Size() + return nil +} diff --git a/internal/cli/chat_seen_fifo_other_test.go b/internal/cli/chat_seen_fifo_other_test.go new file mode 100644 index 000000000..581d04ecb --- /dev/null +++ b/internal/cli/chat_seen_fifo_other_test.go @@ -0,0 +1,7 @@ +//go:build !unix + +package cli + +import "errors" + +func mkfifoForTest(string) error { return errors.New("no FIFOs on this platform") } diff --git a/internal/cli/chat_seen_fifo_unix_test.go b/internal/cli/chat_seen_fifo_unix_test.go new file mode 100644 index 000000000..c5c8249d1 --- /dev/null +++ b/internal/cli/chat_seen_fifo_unix_test.go @@ -0,0 +1,41 @@ +//go:build unix + +package cli + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "syscall" + "testing" +) + +func mkfifoForTest(path string) error { return syscall.Mkfifo(path, 0600) } + +// A FIFO with a reader opens fine, so only the regular-file check stops +// the log writing into someone's pipe. +func TestChatSeenRefusesFifoWithReader(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + path, _ := ChatSeenPath(12) + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := syscall.Mkfifo(path, 0600); err != nil { + t.Skip("mkfifo:", err) + } + r, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NONBLOCK, 0) + if err != nil { + t.Fatal(err) + } + defer r.Close() + var warn bytes.Buffer + OpenChatSeen(chatSeenOwner(12), nil, &warn).Append("x") + if !strings.Contains(warn.String(), "not a regular file") { + t.Fatalf("warn %q", warn.String()) + } + buf := make([]byte, 8) + if n, _ := r.Read(buf); n != 0 { + t.Fatalf("wrote %q into a FIFO", buf[:n]) + } +} diff --git a/internal/cli/chat_seen_other.go b/internal/cli/chat_seen_other.go new file mode 100644 index 000000000..7bacf8e74 --- /dev/null +++ b/internal/cli/chat_seen_other.go @@ -0,0 +1,9 @@ +//go:build !unix + +package cli + +import "os" + +// This platform has no O_NOFOLLOW; the regular-file check after the open is +// the only guard here. +const chatSeenOpenFlags = os.O_WRONLY | os.O_APPEND | os.O_CREATE diff --git a/internal/cli/chat_seen_test.go b/internal/cli/chat_seen_test.go new file mode 100644 index 000000000..0b26c2ca6 --- /dev/null +++ b/internal/cli/chat_seen_test.go @@ -0,0 +1,306 @@ +package cli + +import ( + "bytes" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "testing" + "time" +) + +// TASK-4071: the chat-seen log a plugin tails to show chat to the human. + +func chatSeenOwner(pid int) SessionOwner { + return SessionOwner{PID: pid, PIDSource: "PAD_SESSION_PID"} +} + +func readChatSeen(t *testing.T, path string) string { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return string(b) +} + +func TestChatSeenAppendsLinesPrivately(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + var warn bytes.Buffer + l := OpenChatSeen(chatSeenOwner(4242), nil, &warn) + path := l.Path() + if want := filepath.Join(os.Getenv("HOME"), ".pad", "chat-seen", "4242.log"); path != want { + t.Fatalf("path %q, want %q", path, want) + } + if _, err := os.Stat(filepath.Dir(path)); !os.IsNotExist(err) { + t.Fatalf("no file or dir before the first line; stat err = %v", err) + } + l.Append("one") + l.Append("two") + if got := readChatSeen(t, path); got != "one\ntwo\n" || warn.Len() != 0 { + t.Fatalf("log %q warn %q", got, warn.String()) + } + if runtime.GOOS == "windows" { + return + } + if info, _ := os.Stat(filepath.Dir(path)); info.Mode().Perm() != 0700 { + t.Fatalf("dir mode %v", info.Mode().Perm()) + } + if info, _ := os.Stat(path); info.Mode().Perm() != 0600 { + t.Fatalf("file mode %v", info.Mode().Perm()) + } +} + +func TestChatSeenTightensLooseDirAndFile(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX modes") + } + t.Setenv("HOME", t.TempDir()) + path, _ := ChatSeenPath(7) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatal(err) + } + if err := os.Chmod(filepath.Dir(path), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("old\n"), 0644); err != nil { + t.Fatal(err) + } + if err := os.Chmod(path, 0644); err != nil { + t.Fatal(err) + } + var warn bytes.Buffer + OpenChatSeen(chatSeenOwner(7), nil, &warn).Append("new") + if got := readChatSeen(t, path); got != "old\nnew\n" || warn.Len() != 0 { + t.Fatalf("log %q warn %q", got, warn.String()) + } + if info, _ := os.Stat(filepath.Dir(path)); info.Mode().Perm() != 0700 { + t.Fatalf("dir mode %v, want 0700", info.Mode().Perm()) + } + if info, _ := os.Stat(path); info.Mode().Perm() != 0600 { + t.Fatalf("file mode %v, want 0600", info.Mode().Perm()) + } +} + +func TestChatSeenRefusesSymlinkAndStops(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlinks") + } + t.Setenv("HOME", t.TempDir()) + path, _ := ChatSeenPath(8) + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + target := filepath.Join(t.TempDir(), "target") + if err := os.WriteFile(target, nil, 0600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, path); err != nil { + t.Fatal(err) + } + var warn bytes.Buffer + l := OpenChatSeen(chatSeenOwner(8), nil, &warn) + l.Append("a") + l.Append("b") + if got := readChatSeen(t, target); got != "" { + t.Fatalf("wrote through a symlink: %q", got) + } + if strings.Count(warn.String(), "\n") != 1 || !strings.Contains(warn.String(), "stopped showing chat lines") { + t.Fatalf("want exactly one warning, got %q", warn.String()) + } +} + +func TestChatSeenRefusesFifoWithoutHanging(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("FIFOs") + } + t.Setenv("HOME", t.TempDir()) + path, _ := ChatSeenPath(9) + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := mkfifoForTest(path); err != nil { + t.Skip("mkfifo:", err) + } + var warn bytes.Buffer + done := make(chan struct{}) + go func() { + OpenChatSeen(chatSeenOwner(9), nil, &warn).Append("x") + close(done) + }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("Append blocked opening a FIFO") + } + if !strings.Contains(warn.String(), "stopped showing chat lines") { + t.Fatalf("warn %q", warn.String()) + } +} + +func TestChatSeenRotatesOneGeneration(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + var warn bytes.Buffer + l := OpenChatSeen(chatSeenOwner(10), nil, &warn) + l.max = 10 + for _, s := range []string{"aaaa", "bbbb", "cccc", "dddd", "eeee"} { + l.Append(s) // 5 bytes each: two fit in one generation + } + if got := readChatSeen(t, l.Path()); got != "eeee\n" { + t.Fatalf("current %q", got) + } + if got := readChatSeen(t, l.Path()+".1"); got != "cccc\ndddd\n" { + t.Fatalf("previous generation %q", got) + } + if warn.Len() != 0 { + t.Fatal(warn.String()) + } +} + +// A line longer than the cap still lands; it is not rotated against itself. +func TestChatSeenOversizeLineOnEmptyLog(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + var warn bytes.Buffer + l := OpenChatSeen(chatSeenOwner(11), nil, &warn) + l.max = 4 + l.Append("longer than four") + if got := readChatSeen(t, l.Path()); got != "longer than four\n" { + t.Fatalf("current %q", got) + } + if _, err := os.Stat(l.Path() + ".1"); !os.IsNotExist(err) { + t.Fatalf("no rotation of an empty log; stat err = %v", err) + } +} + +// C2: a pid from os.Getpid() keys nothing a reader or a prune can find. +func TestChatSeenOffWithoutHarnessPid(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("PAD_SESSION_PID", "") + t.Setenv("CLAUDE_PID", "") + owner, err := CaptureSessionOwner() + if err != nil || owner.PIDSource != "self" { + t.Fatalf("setup: %+v %v", owner, err) + } + var warn bytes.Buffer + l := OpenChatSeen(owner, err, &warn) + if l.Path() != "" || warn.Len() != 0 { + t.Fatalf("want no log and a silent start for a self pid, got %q, warn %q", l.Path(), warn.String()) + } + l.Append("x") + l.Append("y") + if strings.Count(warn.String(), "\n") != 1 || !strings.Contains(warn.String(), "PAD_SESSION_PID") { + t.Fatalf("want one stderr line naming PAD_SESSION_PID, got %q", warn.String()) + } + if _, err := os.Stat(filepath.Join(os.Getenv("HOME"), ".pad", "chat-seen")); !os.IsNotExist(err) { + t.Fatalf("nothing may be created; stat err = %v", err) + } +} + +func TestChatSeenOffOnInvalidSessionPid(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("PAD_SESSION_PID", "nope") + owner, err := CaptureSessionOwner() + var warn bytes.Buffer + l := OpenChatSeen(owner, err, &warn) + l.Append("x") + if l.Path() != "" || !strings.Contains(warn.String(), "PAD_SESSION_PID") { + t.Fatalf("log %q warn %q", l.Path(), warn.String()) + } +} + +// C1's pin: the mod passes its sessionPid() as PAD_SESSION_PID, and that +// keys the log ahead of CLAUDE_PID, so the reader and writer agree. +func TestChatSeenKeyedByPadSessionPidFirst(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + ppid := os.Getppid() + t.Setenv("PAD_SESSION_PID", strconv.Itoa(ppid)) + t.Setenv("CLAUDE_PID", "1") + owner, err := CaptureSessionOwner() + var warn bytes.Buffer + l := OpenChatSeen(owner, err, &warn) + if filepath.Base(l.Path()) != strconv.Itoa(ppid)+".log" { + t.Fatalf("log %v warn %q", l, warn.String()) + } + t.Setenv("PAD_SESSION_PID", "") + owner, err = CaptureSessionOwner() + if l := OpenChatSeen(owner, err, &warn); filepath.Base(l.Path()) != "1.log" { + t.Fatalf("CLAUDE_PID fallback: log %q", l.Path()) + } +} + +func TestPruneRemovesDeadSessionsChatSeen(t *testing.T) { + dir := registryEnv(t) + dead := exitedProcessPID(t) + writeV2Record(t, dir, SessionRegistration{SessionOwner: SessionOwner{PID: dead, PIDSource: "CLAUDE_PID"}, Agent: "gone", Cwd: "/b", RegisteredAt: "2026-08-01T00:00:00Z"}) + writeV2Record(t, dir, SessionRegistration{SessionOwner: SessionOwner{PID: os.Getpid(), PIDSource: "CLAUDE_PID"}, Agent: "live", Cwd: "/a", RegisteredAt: "2026-08-01T00:00:00Z"}) + var warn bytes.Buffer + deadLog := OpenChatSeen(chatSeenOwner(dead), nil, &warn) + deadLog.Append("x") + if err := os.WriteFile(deadLog.Path()+".1", []byte("y\n"), 0600); err != nil { + t.Fatal(err) + } + liveLog := OpenChatSeen(chatSeenOwner(os.Getpid()), nil, &warn) + liveLog.Append("z") + + rep, err := PruneSessions(0, time.Now()) + if err != nil || rep.DeadRemoved != 1 { + t.Fatalf("prune: %+v %v", rep, err) + } + for _, p := range []string{deadLog.Path(), deadLog.Path() + ".1"} { + if _, err := os.Stat(p); !os.IsNotExist(err) { + t.Fatalf("%s must go with its record; stat err = %v", p, err) + } + } + if got := readChatSeen(t, liveLog.Path()); got != "z\n" { + t.Fatalf("a live session's log must stay: %q", got) + } +} + +// r1: ensurePrivateDir follows a symlink, so a symlinked chat-seen directory +// would have its target chmodded and the log written into it. +func TestChatSeenRefusesSymlinkedDir(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlinks") + } + t.Setenv("HOME", t.TempDir()) + path, _ := ChatSeenPath(13) + target := t.TempDir() + if err := os.Chmod(target, 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(filepath.Dir(path)), 0700); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, filepath.Dir(path)); err != nil { + t.Fatal(err) + } + var warn bytes.Buffer + OpenChatSeen(chatSeenOwner(13), nil, &warn).Append("x") + if !strings.Contains(warn.String(), "is a symlink") { + t.Fatalf("warn %q", warn.String()) + } + if info, _ := os.Stat(target); info.Mode().Perm() != 0755 { + t.Fatalf("the symlink's target was chmodded to %v", info.Mode().Perm()) + } + if entries, _ := os.ReadDir(target); len(entries) != 0 { + t.Fatalf("wrote into the symlink's target: %v", entries) + } +} + +// r1: a log removed under the writer (a prune of an unknown-liveness record) +// is recreated at its path rather than written into an unlinked file. +func TestChatSeenReopensAfterRemoval(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + var warn bytes.Buffer + l := OpenChatSeen(chatSeenOwner(14), nil, &warn) + l.Append("before") + if err := removeChatSeen(14); err != nil { + t.Fatal(err) + } + l.Append("after") + if got := readChatSeen(t, l.Path()); got != "after\n" || warn.Len() != 0 { + t.Fatalf("log %q warn %q", got, warn.String()) + } +} diff --git a/internal/cli/chat_seen_unix.go b/internal/cli/chat_seen_unix.go new file mode 100644 index 000000000..d24a92548 --- /dev/null +++ b/internal/cli/chat_seen_unix.go @@ -0,0 +1,14 @@ +//go:build unix + +package cli + +import ( + "os" + "syscall" +) + +// O_NOFOLLOW refuses a symlink planted at the log's path. O_NONBLOCK keeps a +// FIFO there from hanging the open (it fails ENXIO with no reader, and any +// non-regular file is refused after the open); it changes nothing for a +// regular file. +const chatSeenOpenFlags = os.O_WRONLY | os.O_APPEND | os.O_CREATE | syscall.O_NOFOLLOW | syscall.O_NONBLOCK diff --git a/internal/cli/session_registry.go b/internal/cli/session_registry.go index c54da406c..d721d3caf 100644 --- a/internal/cli/session_registry.go +++ b/internal/cli/session_registry.go @@ -133,16 +133,12 @@ func SessionsDir() (string, error) { return "", fmt.Errorf("get home directory: %w", err) } dir := filepath.Join(homeDir, ".pad", "sessions") - if err := os.MkdirAll(dir, 0700); err != nil { - return "", fmt.Errorf("create sessions directory: %w", err) - } - // MkdirAll applies the mode only on creation; a directory that already - // existed with a looser mode keeps it. The registry's records are - // 0600 each, but a permissive directory would let another local user - // see the filenames (pids) and plant files — tighten it every time + // The registry's records are 0600 each, but a permissive directory + // would let another local user see the filenames (pids) and plant + // files, so ensurePrivateDir tightens an existing one every time // (codex round 3). Best effort: the caller's own directory. - if info, err := os.Stat(dir); err == nil && info.Mode().Perm() != 0700 { - _ = os.Chmod(dir, 0700) + if err := ensurePrivateDir(dir); err != nil { + return "", fmt.Errorf("create sessions directory: %w", err) } return dir, nil } @@ -515,8 +511,8 @@ type PruneReport struct { } // PruneSessions removes DEAD records, and — only when olderThan > 0 — -// UNKNOWN records whose timestamp is older than now-olderThan. It never -// removes an ALIVE record. The age bound is the only way a registry on a +// UNKNOWN records whose timestamp is older than now-olderThan, each with +// its chat-seen log (TASK-4071). It never removes an ALIVE record. The age bound is the only way a registry on a // platform that cannot probe pids ever shrinks, and it is deliberately an // explicit choice: register passes 0. // @@ -569,6 +565,10 @@ func pruneLocked(olderThan time.Duration, now time.Time) (PruneReport, error) { if err := os.Remove(rec.Path); err != nil && !errors.Is(err, os.ErrNotExist) { return rep, fmt.Errorf("remove %s: %w", rec.Path, err) } + // The session's chat-seen log goes with its record (TASK-4071). + // Best effort: a log left behind is display state, not a reason to + // fail the prune. + _ = removeChatSeen(rec.SessionPID) rep.Removed = append(rep.Removed, rec) if rec.Liveness == LivenessDead { rep.DeadRemoved++ diff --git a/plugin/hooks/register.js b/plugin/hooks/register.js index 8dab6cd37..1bbea101f 100644 --- a/plugin/hooks/register.js +++ b/plugin/hooks/register.js @@ -1,7 +1,8 @@ // The Pad pane inside Claude Code (IDEA-3477; folded into the pad plugin by TASK-3486). // -// One command, /pad-pane, opens a pane on the linked workspace. Nothing else: -// no blocking, no band, no changes to what Claude sees. Session start runs +// One command, /pad-pane, opens a pane on the linked workspace. Nothing else +// changes what Claude sees, and nothing blocks. The one band is the chat-seen +// band (TASK-4071): chat delivered to the agent, shown to the person too. Session start runs // nothing (TASK-3970); after a plugin reload it only picks back up a pane that // was already open. Otherwise the mod listens for Claude touching Pad: the // first touch wakes the session (presence, live events; see wake), and an @@ -1098,6 +1099,10 @@ function wake($) { let registered = () => {} woken = new Promise((r) => { registered = r }) startWatch($, registered).catch(() => {}).finally(registered) + // TASK-4071: the chat-seen reader starts with the session's first use of + // Pad too, armed or not: /pad:connect may arm the session later, and its + // monitor writes the same file. + startSeen($).catch(() => {}) return woken } @@ -1200,6 +1205,225 @@ function stopRefresh() { refresh = null } +// ---------------------------------------------------------------- chat seen + +// TASK-4071: a chat line delivered to the agent is shown to the person at this +// terminal too. `pad watch --stream --for-session` appends each chat line it +// prints to ~/.pad/chat-seen/.log; this tails that file into a band +// above the prompt (the last few lines, until the person's next prompt) and a +// toast per burst. One reader for both launch paths: the mod's own stream and +// the /pad:connect monitor write the same file, keyed by the same pid. +// +// It is a display cue, never a record (the agent's stdout is). Reading starts +// at the end of whatever the file holds, so a /clear or a recycled pid never +// replays old lines. A rotation (the writer moves a full log to .1) is seen as +// the file shrinking; lines that rotated away between two polls are not shown, +// and nothing catches them up (TASK-4071 C3). A rotated log that grows back past +// the old position within one poll is misread as a continuation, for one poll. +const SEEN_POLL_MS = 1000 +const SEEN_BAND_LINES = 3 +const SEEN_LINE_MAX = 300 +let seenTimer = null +let seenStarting = false +let seenEpoch = 0 // bumped by stopSeen: a start or poll still awaiting then changes nothing +let seenPath = '' +let seenSize = -1 // bytes at the last successful look; -1 until the first +let seenChars = 0 // characters of the file already handled, up to its last newline +let seenPrimed = false // the first look only finds the end +let seenPolling = false +let seenBand = [] // the lines the band shows +let toastLines = [] // new lines not yet toasted: one toast per burst +let toastTimer = null +let toastStart = 0 + +// Chat text is another party's words, drawn on the person's terminal: drop +// control characters (C0, DEL, C1, so no escape sequence reaches it) and the +// bidirectional controls that can make a line read as something else. The +// log itself stays byte for byte what the agent got. +const UNSAFE = /[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g +export const displayLine = (s) => String(s).replace(UNSAFE, '') + +// Cut by code points, so an emoji at the edge is never split in half. +export function clipLine(s, n) { + const cps = Array.from(s) + return cps.length > n ? cps.slice(0, n - 1).join('') + '…' : s +} + +// The person's own prompt, typed here or through Remote Control: what clears +// the band. A prompt this mod relays, or a background task's, does not. +export function isPersonPrompt(e) { + const kind = e?.origin?.kind + return kind === 'composer' || kind === 'bridge' +} + +async function chatSeenPath($) { + const pid = await sessionPid($) + if (!pid) return '' + try { + const r = await $.process.run(['sh', '-c', 'printf %s "$HOME"'], { timeoutMs: 5000 }) + const home = String(r.stdout || '') + return home.startsWith('/') ? home.replace(/\/+$/, '') + '/.pad/chat-seen/' + pid + '.log' : '' + } catch { + return '' + } +} + +async function startSeen($) { + if (seenTimer || seenStarting) return + seenStarting = true + const epoch = seenEpoch + try { + const path = await chatSeenPath($) + if (!path || epoch !== seenEpoch) return + seenPath = path + await pollSeen($) + if (epoch !== seenEpoch) return + seenTimer = $.clock.every(SEEN_POLL_MS, () => { pollSeen($).catch(() => {}) }) + } finally { + if (epoch === seenEpoch) seenStarting = false + } +} + +function stopSeen() { + seenEpoch++ + seenStarting = false + seenPolling = false // a poll still in flight finishes under the old epoch + if (seenTimer) seenTimer.cancel() + seenTimer = null + if (toastTimer) toastTimer.cancel() + toastTimer = null + toastLines = [] + toastStart = 0 + seenPath = '' + seenSize = -1 + seenChars = 0 + seenPrimed = false + seenBand = [] +} + +async function pollSeen($) { + if (seenPolling || !seenPath) return + const epoch = seenEpoch + const path = seenPath + seenPolling = true + try { + let size = -1 // unknown: the stat failed, or the path is a symlink + try { + const st = await $.fs.stat(path) + if (st.kind === 'file' && !st.isLink) size = st.size + } catch {} + if (epoch !== seenEpoch) return + if (size < 0) { + // No log at the first look: every line from now on is new. A look that + // only FAILED proves nothing, so it leaves the start to the first good + // read; and once started, an unreadable moment changes nothing. Either + // way it cannot cause a replay. + if (!seenPrimed && (await logAbsent($, path)) && epoch === seenEpoch) { + seenSize = 0 + seenChars = 0 + seenPrimed = true + } + return + } + if (size === seenSize) return + if (!size) { + seenSize = 0 + seenChars = 0 + seenPrimed = true + return + } + let text + try { + text = await $.fs.read(path) + } catch { + return // nothing advanced: the next poll tries again + } + if (epoch !== seenEpoch) return + // Rotated (or removed and recreated): read the new log from its start. In + // bytes here and in characters below, since a log of multi-byte text can + // shrink in one while it grows in the other. + if (size < seenSize) seenChars = 0 + seenSize = size + if (!seenPrimed) { + // The first look skips what the file held at the stat; a line written + // between that stat and this read is new, and shows. + const cut = charIndexAtByte(text, size) + seenChars = text.lastIndexOf('\n', cut - 1) + 1 + seenPrimed = true + } + if (text.length < seenChars) seenChars = 0 + const end = text.lastIndexOf('\n') + 1 // a line still being written waits for its newline + if (end <= seenChars) return + const fresh = text.slice(seenChars, end).split('\n').map(displayLine).filter((l) => l.trim()) + seenChars = end + if (!fresh.length) return + seenBand = [...seenBand, ...fresh].slice(-SEEN_BAND_LINES).map((l) => clipLine(l, SEEN_LINE_MAX)) + redraw($) + await queueToast($, fresh, epoch) + } finally { + if (epoch === seenEpoch) seenPolling = false + } +} + +// True only when the listing shows no log at path (or no directory for it). +async function logAbsent($, path) { + const cut = path.lastIndexOf('/') + const dir = path.slice(0, cut) + const name = path.slice(cut + 1) + try { + return !(await $.fs.list(dir)).some((e) => e.name === name) + } catch {} + const up = dir.lastIndexOf('/') + try { + return !(await $.fs.list(dir.slice(0, up))).some((e) => e.name === dir.slice(up + 1)) + } catch { + return false + } +} + +// The index in text (UTF-16 units) where its first `bytes` UTF-8 bytes end. +export function charIndexAtByte(text, bytes) { + let b = 0 + let i = 0 + while (i < text.length && b < bytes) { + const cp = text.codePointAt(i) + b += cp < 0x80 ? 1 : cp < 0x800 ? 2 : cp < 0x10000 ? 3 : 4 + i += cp > 0xffff ? 2 : 1 + } + return i +} + +// One toast per burst, on the same clock as the lines relayed to Claude: a +// burst ends after WATCH_BURST_MS of quiet, or WATCH_BURST_MAX_MS after it began. +async function queueToast($, lines, epoch) { + toastLines.push(...lines) + const now = await $.clock.now() + if (epoch !== seenEpoch) return + if (!toastStart) toastStart = now + if (toastTimer) toastTimer.cancel() + const wait = Math.max(0, Math.min(WATCH_BURST_MS, toastStart + WATCH_BURST_MAX_MS - now)) + toastTimer = $.clock.after(wait, () => { + toastTimer = null + toastStart = 0 + const burst = toastLines + toastLines = [] + if (!burst.length) return + const last = clipLine(burst[burst.length - 1], 120) + $.ui.toast(burst.length > 1 ? last + ' (+' + (burst.length - 1) + ' more)' : last) + }) +} + +function drawSeenBand($, e) { + const { Box, Text } = $.ui.resolve(e) + return Box({ + flexDirection: 'column', + children: [ + Text({ dimColor: true, children: ['Pad chat (delivered to the agent)'] }), + ...seenBand.map((l) => Text({ wrap: 'truncate-end', children: [l] })), + ], + }) +} + // ---------------------------------------------------------------- hooks export function register(on) { @@ -1226,7 +1450,10 @@ export function register(on) { // what consent is keyed on, and session.start does not fire again after // them: the stream stays. Any other end stops it. on('session.end', async ($, e, next) => { - if (e.reason !== 'clear' && e.reason !== 'resume') stopWatch() + if (e.reason !== 'clear' && e.reason !== 'resume') { + stopWatch() + stopSeen() + } return next(e) }) @@ -1301,6 +1528,21 @@ export function register(on) { return {} }) + // The chat-seen band (TASK-4071) stays until the person's own next prompt; + // a prompt this mod or anything else submits does not clear it. + on('prompt.submit', async ($, e, next) => { + if (seenBand.length && isPersonPrompt(e)) { + seenBand = [] + redraw($) + } + return next(e) + }) + + on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => { + if (!seenBand.length || e.props?.hasSurvey) return next(e) + return drawSeenBand($, e) + }) + on('ui.render', { component: 'Pane' }, async ($, e, next) => { if (e.requestId !== PANE) return next(e) return draw($, e) diff --git a/plugin/tests/seen.test.ts b/plugin/tests/seen.test.ts new file mode 100644 index 000000000..ec8e57b5e --- /dev/null +++ b/plugin/tests/seen.test.ts @@ -0,0 +1,380 @@ +import { expect, mock, test } from 'claude-code/testing' +import { charIndexAtByte, clipLine, displayLine, isPersonPrompt } from '../hooks/register.js' + +// TASK-4071: chat delivered to the agent is shown to the person too. `pad watch` +// appends each chat line to ~/.pad/chat-seen/.log; the mod tails +// it into a band above the prompt and a toast per burst. These tests stand in +// for the CLI, the shell and the file. + +const LOG = '/home/t/.pad/chat-seen/4242.log' +const DIR = '/home/t/.pad/chat-seen' + +// What the log's stat reports: UTF-8 bytes, not characters. +function utf8Bytes(s: string) { + let n = 0 + for (const ch of s) { + const cp = ch.codePointAt(0) as number + n += cp < 0x80 ? 1 : cp < 0x800 ? 2 : cp < 0x10000 ? 3 : 4 + } + return n +} + +const BAND = { + plugin: 'pad', + component: 'AbovePrompt', + surface: 'terminal', + viewport: { columns: 120, rows: 40 }, + props: { hasSurvey: false, isWorking: false, maxRows: 10, bodyColumns: 115, scroll: { offset: 0, bodyRows: 10 }, view: {} }, +} as const + +function stubs(on: any, initial: string | null = null, opts: { failReads?: number; failStats?: number; holdHome?: Promise; growOnFirstRead?: string; holdFirstRead?: Promise } = {}) { + const file = { text: initial as string | null, isLink: false } + let failReads = opts.failReads || 0 + const toasts: string[] = [] + const statted: string[] = [] + on('tool.call', () => ({ result: { stdout: '', stderr: '' }, text: '' })) + on('session.start', ($: any, e: any) => ({ cwd: e.cwd })) + on('session.end', () => ({ sessionId: 's1' })) + on('command.register', () => ({ value: undefined })) + on('store.get', () => ({ value: undefined })) + on('ui.panes', () => ({ value: [] })) + // The engine's own band beneath the mod: empty. + on('ui.render', ($: any, e: any) => $.ui.resolve(e).Text({ children: ['engine'] })) + on('ui.toast', ($: any, e: any) => { toasts.push(String(e.text)); return { value: undefined } }) + for (const name of ['ui.log', 'ui.status', 'ui.notice']) on(name, () => ({ value: undefined })) + on('process.run', ($: any, e: any) => { + const argv = e.argv.join(' ') + if (argv.includes('$PPID')) return { value: { exitCode: 0, stdout: '4242\n', stderr: '' } } + if (argv.includes('$HOME')) { + const answer = { value: { exitCode: 0, stdout: '/home/t', stderr: '' } } + return opts.holdHome ? opts.holdHome.then(() => answer) : answer + } + // pad session register / should-arm: unarmed, so no stream is spawned and + // the reader is what is under test. + return { value: { exitCode: 1, stdout: '', stderr: '' } } + }) + let failStats = opts.failStats || 0 + on('fs.stat', ($: any, e: any) => { + statted.push(e.path) + if (failStats > 0) { failStats--; return { deny: 'EIO' } } + if (e.path !== LOG || file.text === null) return { deny: 'ENOENT' } + return { value: { kind: 'file', size: utf8Bytes(file.text), mtimeMs: 0, isLink: file.isLink } } + }) + on('fs.list', ($: any, e: any) => { + if (e.path !== DIR) return { deny: 'ENOENT' } + return { value: file.text === null ? [] : [{ name: '4242.log', kind: file.isLink ? 'other' : 'file', size: 0, mtimeMs: 0 }] } + }) + on('fs.read', ($: any, e: any) => { + if (e.path !== LOG || file.text === null) return { deny: 'ENOENT' } + if (failReads > 0) { failReads--; return { deny: 'EIO' } } + if (opts.growOnFirstRead !== undefined) { + // a line the writer appends between the reader's stat and its read + file.text += opts.growOnFirstRead + opts.growOnFirstRead = undefined + } + if (opts.holdFirstRead) { + const held = opts.holdFirstRead + opts.holdFirstRead = undefined + const text = file.text + return held.then(() => ({ value: text })) + } + return { value: file.text } + }) + return { file, toasts, statted } +} + +const settle = () => new Promise((r) => setTimeout(r, 30)) +const usePad = ($: any) => $.tool.call({ tool: 'Bash', command: 'pad bootstrap --format json' }) + +async function bandTexts($: any) { + const ui = await $.ui.mount(BAND) + const found = await ui.findAll({ type: "Text" }) + await ui.unmount() + return found.map((t: any) => t.text).filter((t: string) => t !== 'engine') +} + +// One poll, then the burst's quiet: a toast shows once a burst ends. +async function poll(clock: any) { + await clock.advance(1000) + await settle() + await clock.advance(1500) + await settle() +} + +// One poll inside a burst. +async function pollQuick(clock: any) { + await clock.advance(1000) + await settle() +} + +test('starts at the end of the log: lines already there are never shown', async ($, on) => { + const clock = mock.clock(on) + const s = stubs(on, 'old line from a past session\n') + await $.session.start({ cwd: '/work/demo' }) + await usePad($) + await settle() + expect(s.statted).toContain(LOG) + await poll(clock) + expect(s.toasts).toEqual([]) + expect(await bandTexts($)).toEqual([]) +}) + +test('a new chat line shows in the band and as a toast', async ($, on) => { + const clock = mock.clock(on) + const s = stubs(on, 'old\n') + await $.session.start({ cwd: '/work/demo' }) + await usePad($) + await settle() + s.file.text += 'Message from rook via Pad: hello\n' + await poll(clock) + expect(s.toasts).toEqual(['Message from rook via Pad: hello']) + const texts = await bandTexts($) + expect(texts).toContain('Message from rook via Pad: hello') + expect(texts.some((t: string) => /Pad chat/.test(t))).toBe(true) +}) + +test('a burst is one toast, and the band keeps the last three lines', async ($, on) => { + const clock = mock.clock(on) + const s = stubs(on, '') + await $.session.start({ cwd: '/work/demo' }) + await usePad($) + await settle() + s.file.text += 'one\ntwo\n' + await pollQuick(clock) + expect(s.toasts).toEqual([]) + s.file.text += 'three\nfour\nfive\n' + await poll(clock) + expect(s.toasts).toEqual(['five (+4 more)']) + const texts = await bandTexts($) + expect(texts.filter((t: string) => !/Pad chat/.test(t))).toEqual(['three', 'four', 'five']) +}) + +test('a line still being written waits for its newline', async ($, on) => { + const clock = mock.clock(on) + const s = stubs(on, '') + await $.session.start({ cwd: '/work/demo' }) + await usePad($) + await settle() + s.file.text += 'half a li' + await poll(clock) + expect(s.toasts).toEqual([]) + s.file.text += 'ne\n' + await poll(clock) + expect(s.toasts).toEqual(['half a line']) +}) + +test('a rotated log is read from its start (C3: lines rotated away between polls are lost)', async ($, on) => { + const clock = mock.clock(on) + const s = stubs(on, 'a long line that fills the log\n') + await $.session.start({ cwd: '/work/demo' }) + await usePad($) + await settle() + s.file.text = 'after rotation\n' // shorter: the writer moved the full log to .1 + await poll(clock) + expect(s.toasts).toEqual(['after rotation']) +}) + +test('no log yet shows nothing; the first line written then shows', async ($, on) => { + const clock = mock.clock(on) + const s = stubs(on, null) + await $.session.start({ cwd: '/work/demo' }) + await usePad($) + await settle() + await poll(clock) + expect(s.toasts).toEqual([]) + s.file.text = 'first\n' + await poll(clock) + expect(s.toasts).toEqual(['first']) +}) + +test('a symlink at the log path is not read', async ($, on) => { + const clock = mock.clock(on) + const s = stubs(on, '') + await $.session.start({ cwd: '/work/demo' }) + await usePad($) + await settle() + s.file.isLink = true + s.file.text = 'planted\n' + await poll(clock) + expect(s.toasts).toEqual([]) +}) + +test('the reader survives /clear and /resume; any other end stops it', async ($, on) => { + const clock = mock.clock(on) + const s = stubs(on, '') + await $.session.start({ cwd: '/work/demo' }) + await usePad($) + await settle() + await $.session.end({ reason: 'clear' }) + await $.session.end({ reason: 'resume' }) + s.file.text += 'still here\n' + await poll(clock) + expect(s.toasts).toEqual(['still here']) + await $.session.end({ reason: 'prompt_input_exit' }) + s.file.text += 'after the end\n' + await poll(clock) + expect(s.toasts).toEqual(['still here']) + expect(await bandTexts($)).toEqual([]) +}) + +test('nothing reads the log before the session first uses Pad (TASK-3970)', async ($, on) => { + const clock = mock.clock(on) + const s = stubs(on, '') + await $.session.start({ cwd: '/work/demo' }) + await settle() + s.file.text += 'early\n' + await poll(clock) + expect(s.statted).toEqual([]) + expect(s.toasts).toEqual([]) +}) + +test('the band stays through a prompt the person did not type', async ($, on) => { + const clock = mock.clock(on) + on('prompt.submit', ($: any, e: any) => ({ text: e.text })) + const s = stubs(on, '') + await $.session.start({ cwd: '/work/demo' }) + await usePad($) + await settle() + s.file.text += 'kept\n' + await poll(clock) + await $.prompt.submit({ text: 'relayed by a plugin' }) + await settle() + expect(await bandTexts($)).toContain('kept') +}) + +test('only the person’s own prompt clears the band', () => { + expect(isPersonPrompt({ origin: { kind: 'composer' } })).toBe(true) + expect(isPersonPrompt({ origin: { kind: 'bridge' } })).toBe(true) + for (const kind of ['plugin', 'task', 'peer', 'sdk', 'unclassified']) { + expect(isPersonPrompt({ origin: { kind } })).toBe(false) + } + expect(isPersonPrompt({})).toBe(false) +}) + +test('control characters and direction overrides never reach the terminal', async ($, on) => { + const clock = mock.clock(on) + const s = stubs(on, '') + await $.session.start({ cwd: '/work/demo' }) + await usePad($) + await settle() + s.file.text += 'hi \u001b]52;c;ZXZpbA==\u0007 there \u202egnp.exe\u009b\n' + await poll(clock) + expect(s.toasts).toEqual(['hi ]52;c;ZXZpbA== there gnp.exe']) + expect(await bandTexts($)).toContain('hi ]52;c;ZXZpbA== there gnp.exe') +}) + +test('displayLine and clipLine', () => { + expect(displayLine('a\u001b[31mb\u007fc\u200fd\u2066e\u061cf')).toBe('a[31mbcdef') + const mixed = 'aé😀b' // UTF-8 bytes: 1, 2, 4, 1 + expect(charIndexAtByte(mixed, 3)).toBe(2) + expect(charIndexAtByte(mixed, 7)).toBe(4) + expect(charIndexAtByte(mixed, 8)).toBe(5) + expect(clipLine('😀😀😀', 2)).toBe('😀…') + expect(clipLine('a😀b', 3)).toBe('a😀b') +}) + +test('a session that ends while the reader is starting reads nothing', async ($, on) => { + const clock = mock.clock(on) + let release = () => {} + const holdHome = new Promise((r) => { release = r }) + const s = stubs(on, '', { holdHome }) + await $.session.start({ cwd: '/work/demo' }) + await usePad($) + await settle() + await $.session.end({ reason: 'prompt_input_exit' }) + release() + await settle() + s.file.text += 'after the end\n' + await poll(clock) + await poll(clock) + expect(s.statted).toEqual([]) + expect(s.toasts).toEqual([]) +}) + +test('a first read that fails does not replay the log later', async ($, on) => { + const clock = mock.clock(on) + const s = stubs(on, 'old one\nold two\n', { failReads: 1 }) + await $.session.start({ cwd: '/work/demo' }) + await usePad($) + await settle() + await poll(clock) + s.file.text += 'new\n' + await poll(clock) + expect(s.toasts).toEqual(['new']) +}) + +test('a line written between the first stat and the first read still shows', async ($, on) => { + const clock = mock.clock(on) + const s = stubs(on, 'old\n', { growOnFirstRead: 'arrived during the first look\n' }) + await $.session.start({ cwd: '/work/demo' }) + await usePad($) + await settle() + await poll(clock) + expect(s.toasts).toEqual(['arrived during the first look']) +}) + +test('a session that ends during a poll can read again after its next wake', async ($, on) => { + const clock = mock.clock(on) + let release = () => {} + const holdFirstRead = new Promise((r) => { release = r }) + const s = stubs(on, 'old\n', { holdFirstRead }) + await $.session.start({ cwd: '/work/demo' }) + await usePad($) + await settle() + await $.session.end({ reason: 'prompt_input_exit' }) + release() + await settle() + await usePad($) + await settle() + s.file.text += 'after the second wake\n' + await poll(clock) + expect(s.toasts).toEqual(['after the second wake']) +}) + +test('a moment the log cannot be read does not replay it', async ($, on) => { + const clock = mock.clock(on) + const s = stubs(on, 'old one\nold two\n') + await $.session.start({ cwd: '/work/demo' }) + await usePad($) + await settle() + s.file.isLink = true + await poll(clock) + s.file.isLink = false + s.file.text += 'new\n' + await poll(clock) + expect(s.toasts).toEqual(['new']) +}) + +test('a first look that only failed does not start the log at zero (r3)', async ($, on) => { + const clock = mock.clock(on) + const s = stubs(on, 'old one\nold two\n', { failStats: 1 }) + await $.session.start({ cwd: '/work/demo' }) + await usePad($) + await settle() + await poll(clock) + s.file.text += 'new\n' + await poll(clock) + expect(s.toasts).toEqual(['new']) +}) + +test('a rotation that shrinks the log in bytes shows the new log whole (r3)', async ($, on) => { + const clock = mock.clock(on) + const s = stubs(on, 'é'.repeat(20) + '\n') // 41 bytes, 21 characters + await $.session.start({ cwd: '/work/demo' }) + await usePad($) + await settle() + s.file.text = 'abcdefghijklmnopqrstuvwxyz0123\n' // 31 bytes, 31 characters + await poll(clock) + expect(s.toasts).toEqual(['abcdefghijklmnopqrstuvwxyz0123']) +}) + +test('a rotation that shrinks the log in characters shows the new log whole (r3)', async ($, on) => { + const clock = mock.clock(on) + const s = stubs(on, 'abcdefghijklmnopqrstuvwxyz0123\n') // 31 bytes, 31 characters + await $.session.start({ cwd: '/work/demo' }) + await usePad($) + await settle() + s.file.text = 'é'.repeat(20) + '\n' // 41 bytes, 21 characters + await poll(clock) + expect(s.toasts).toEqual(['é'.repeat(20)]) +})