mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-25 14:00:40 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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") }
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)])
|
||||
})
|
||||
Reference in New Issue
Block a user