feat(chat): show chat delivered to an agent to the human at its terminal (TASK-4071) (#2312)

* feat(chat): tee monitor chat lines to a per-session chat-seen log (TASK-4071)

pad watch --stream --for-session appends every chat line it prints to
~/.pad/chat-seen/<harness-pid>.log (0600 in a 0700 dir, O_NOFOLLOW),
capped at 256 KiB with one .1 generation, so the human at the agent's
terminal can see it. No log when the pid is pad watch's own; pruning a
session record removes its log.

Claude-Session: https://claude.ai/code/session_017ofcsFmgnoVDPxeHwuxCjg

* test(chat): pin the chat-seen tee, perms, rotation, prune and C2 (TASK-4071)

Claude-Session: https://claude.ai/code/session_017ofcsFmgnoVDPxeHwuxCjg

* test(chat): refuse a FIFO that has a reader (TASK-4071 M13)

Claude-Session: https://claude.ai/code/session_017ofcsFmgnoVDPxeHwuxCjg

* feat(plugin): show chat delivered to the agent in a band and a toast (TASK-4071)

The pad mod tails ~/.pad/chat-seen/<harness pid>.log from the session's
first use of Pad, starting at the end of the file, and shows new lines in
a band above the prompt (the last three, until the person's next prompt)
and one toast per burst. Lines rotated away between polls are not shown.

Claude-Session: https://claude.ai/code/session_017ofcsFmgnoVDPxeHwuxCjg

* refactor(plugin): one rotation guard in the chat-seen reader (TASK-4071 P5)

Claude-Session: https://claude.ai/code/session_017ofcsFmgnoVDPxeHwuxCjg

* fix(chat): report a log that is off on its first line, and say what prune and the monitor do with it (TASK-4071)

The monitor prints nothing before it has something to deliver (DOC-2479),
so the no-harness-pid line waits for the first chat line. The pad watch
--stream --for-session and pad session prune help now describe the log.

Claude-Session: https://claude.ai/code/session_017ofcsFmgnoVDPxeHwuxCjg

* fix(chat): r1 findings on the chat-seen log and reader (TASK-4071)

- Refuse a symlinked chat-seen directory instead of chmodding its target.
- Reopen a log that was removed under the writer.
- Remove .log.1 before rotating: Rename does not replace on Windows.
- Reader: drop control characters and direction overrides before they
  reach the terminal; clip by code points; one toast per burst on the
  relay's clock; a stop during start leaves nothing running; a failed
  first read no longer replays the log.

Claude-Session: https://claude.ai/code/session_017ofcsFmgnoVDPxeHwuxCjg

* fix(plugin): r2 findings on the chat-seen reader (TASK-4071)

- The first look skips what the log held at its stat, so a line written
  between that stat and the read still shows.
- A stop during a poll no longer leaves the reader stuck after the next wake.
- An unreadable moment (failed stat, a symlink) changes nothing, so it
  cannot cause a replay.
- U+061C joins the stripped bidi controls.

Claude-Session: https://claude.ai/code/session_017ofcsFmgnoVDPxeHwuxCjg

* fix(plugin): r3 findings on the chat-seen reader (TASK-4071)

- Start a log at zero only when the listing shows it absent; a first look
  that merely failed leaves the start to the first good read (R3-1).
- Restore the byte-size rotation reset beside the character one: a log of
  multi-byte text can shrink in one while it grows in the other (R3-2).

Claude-Session: https://claude.ai/code/session_017ofcsFmgnoVDPxeHwuxCjg
This commit is contained in:
xarmian
2026-10-25 07:17:51 -04:00
committed by GitHub
parent 0a32eff274
commit ffecfc744b
13 changed files with 1381 additions and 20 deletions
+3 -2
View File
@@ -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/<pid>.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
+14
View File
@@ -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/<harness pid>.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 <pid>.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/<harness-pid>.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 {
+18 -4
View File
@@ -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
}
+95
View File
@@ -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())
}
}
+238
View File
@@ -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/<harness-pid>.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 <pid>.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 <pid>.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
}
@@ -0,0 +1,7 @@
//go:build !unix
package cli
import "errors"
func mkfifoForTest(string) error { return errors.New("no FIFOs on this platform") }
+41
View File
@@ -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])
}
}
+9
View File
@@ -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
+306
View File
@@ -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())
}
}
+14
View File
@@ -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
+11 -11
View File
@@ -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++
+245 -3
View File
@@ -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/<harness pid>.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)
+380
View File
@@ -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/<harness pid>.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<void>; growOnFirstRead?: string; holdFirstRead?: Promise<void> } = {}) {
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<void>((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<void>((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)])
})