diff --git a/internal/agentstore/index.go b/internal/agentstore/index.go index e9c86a1..672c87a 100644 --- a/internal/agentstore/index.go +++ b/internal/agentstore/index.go @@ -594,6 +594,38 @@ func (x *Index) Resolve(tool Tool, id string) (string, Summary, bool) { return "", Summary{}, false } +// MatchSession finds the newest live transcript in cwd whose file was +// written since the shell was created. It is the fallback link between a PTY +// session and an agent conversation when no agent hook supplied an exact id. +// +// The match deliberately uses file mtime rather than EndedAt. Timestamps +// inside vendor-owned files are less trustworthy than the filesystem fact +// that the agent just appended to it. The index sweep is poked first; callers +// receive the best snapshot available now and naturally see a newly +// discovered transcript on their next session-list poll. +func (x *Index) MatchSession(cwd string, since time.Time) (Tool, string, bool) { + if cwd == "" { + return "", "", false + } + x.poke() + x.mu.Lock() + defer x.mu.Unlock() + var best *entry + for _, e := range x.entries { + if e == nil || e.ParentPath != "" || e.Missing || e.Summary.MessageCount == 0 || + e.Summary.Cwd != cwd || e.MtimeNs < since.UnixNano() { + continue + } + if best == nil || e.MtimeNs > best.MtimeNs { + best = e + } + } + if best == nil { + return "", "", false + } + return best.Summary.Tool, best.Summary.ID, true +} + func toolAllowed(tool Tool, filter []string) bool { if len(filter) == 0 { return true diff --git a/internal/agentstore/index_test.go b/internal/agentstore/index_test.go index fedab9d..981464c 100644 --- a/internal/agentstore/index_test.go +++ b/internal/agentstore/index_test.go @@ -6,6 +6,7 @@ import ( "path/filepath" "strings" "testing" + "time" ) const ( @@ -111,6 +112,33 @@ func TestSnapshotFilters(t *testing.T) { } } +func TestMatchSessionPicksNewestTranscriptSinceShellStart(t *testing.T) { + home, paths := fakeHome(t) + base := time.Now().Add(-time.Hour).Truncate(time.Second) + for tool, at := range map[Tool]time.Time{ + ToolClaude: base.Add(10 * time.Minute), + ToolCodex: base.Add(20 * time.Minute), + ToolPi: base.Add(30 * time.Minute), + } { + if err := os.Chtimes(paths[tool], at, at); err != nil { + t.Fatalf("Chtimes(%s): %v", tool, err) + } + } + x := New(t.TempDir(), home) + x.sweep() + + tool, id, ok := x.MatchSession("/home/dev/proj", base.Add(15*time.Minute)) + if !ok || tool != ToolPi || id != piFixtureID { + t.Fatalf("MatchSession = (%s, %s, %v), want newest pi transcript", tool, id, ok) + } + if _, _, ok := x.MatchSession("/home/dev/proj", base.Add(40*time.Minute)); ok { + t.Fatal("MatchSession linked a transcript older than the shell") + } + if _, _, ok := x.MatchSession("/somewhere/else", base); ok { + t.Fatal("MatchSession linked a transcript from another cwd") + } +} + // TestSnapshotHidesEmptySessions pins the stub filter: a transcript whose // lines are all bookkeeping (a real store is full of them — hook-only // sessions, bare last-prompt stubs) is indexed but never listed. Its zero diff --git a/internal/daemon/agents.go b/internal/daemon/agents.go index c3b8008..e4905d9 100644 --- a/internal/daemon/agents.go +++ b/internal/daemon/agents.go @@ -2,8 +2,10 @@ package daemon import ( "errors" + "time" "github.com/karnstack/flue/internal/agentstore" + "github.com/karnstack/flue/internal/session" "github.com/karnstack/flue/internal/wire" ) @@ -46,6 +48,17 @@ import ( // goroutine to cap. const maxAgentWork = 4 +// maxAgentFollows bounds the durable file tails one connection can hold. +// Eight covers a densely split workspace while preventing a malformed client +// from turning one socket into an unbounded transcript poller. +const maxAgentFollows = 8 + +// agentFollowPoll is deliberately much faster than the old viewer's five +// second timer while still coalescing the burst of filesystem writes that +// makes one completed agent message. The read itself advances by byte offset, +// so an unchanged file costs an open and stat rather than a reparse. +const agentFollowPoll = 400 * time.Millisecond + // SetAgentIndex installs the transcript index. Nil — a Server nobody wired, // which is every test's default and a daemon whose home directory could not // be resolved — leaves the verbs answering bad_message and keeps the @@ -63,6 +76,22 @@ func (s *Server) agentIndex() *agentstore.Index { return s.agentIdx } +// withAgent enriches one session-list row with the best live transcript link +// the index can prove from cwd and recency. Hooks can make this exact later; +// keeping the fallback here means both WebSocket and loopback HTTP lists use +// the same answer and no session implementation has to know agent stores. +func (s *Server) withAgent(info session.Info) session.Info { + idx := s.agentIndex() + if idx == nil || info.State != "running" { + return info + } + tool, id, ok := idx.MatchSession(info.Cwd, info.CreatedAt) + if ok { + info.Agent, info.AgentSession = string(tool), id + } + return info +} + // handleAgents answers the agents verb. A snapshot of the in-memory index — // no file work — so it runs on the read loop like list does. func (c *conn) handleAgents(m wire.Agents) { @@ -155,6 +184,99 @@ func (c *conn) handleAgentRead(m wire.AgentRead) { }() } +func agentFollowKey(tool, id string) string { return tool + "\x00" + id } + +// handleAgentFollow starts (or moves) a pushed transcript tail. Pages carry +// no reqId: they are events rather than answers, and the client routes them by +// (tool, id). Reads remain bounded by agentstore's ordinary page limits; a +// large append drains page by page without waiting another tick. +func (c *conn) handleAgentFollow(m wire.AgentFollow) { + idx := c.srv.agentIndex() + if idx == nil { + c.sendError("bad_message", "agents unavailable") + return + } + tool := agentstore.Tool(m.Tool) + if _, _, ok := idx.Resolve(tool, m.ID); !ok { + c.sendError("not_found", "cannot follow that transcript") + return + } + if m.Offset < 0 { + m.Offset = 0 + } + key := agentFollowKey(m.Tool, m.ID) + done := make(chan struct{}) + c.mu.Lock() + previous := c.agentFollows[key] + if previous == nil && len(c.agentFollows) >= maxAgentFollows { + c.mu.Unlock() + c.sendError("busy", "too many followed transcripts") + return + } + c.agentFollows[key] = done + c.mu.Unlock() + if previous != nil { + close(previous) + } + + c.pumps.Add(1) + go func(offset int64) { + defer c.pumps.Done() + defer func() { + c.mu.Lock() + if c.agentFollows[key] == done { + delete(c.agentFollows, key) + } + c.mu.Unlock() + }() + + timer := time.NewTimer(0) + defer timer.Stop() + for { + select { + case <-c.ctx.Done(): + return + case <-done: + return + case <-timer.C: + } + + for { + page, err := idx.ReadPage(tool, m.ID, offset, "forward", 100) + if err != nil { + return + } + moved := page.Next != offset + offset = page.Next + if len(page.Messages) > 0 { + if err := c.sendControl(wire.AgentPage{ + Tool: m.Tool, ID: m.ID, Messages: page.Messages, + Start: page.Start, Next: page.Next, Eof: page.Eof, + FileSize: page.FileSize, + }); err != nil { + return + } + } + if page.Eof || !moved { + break + } + } + timer.Reset(agentFollowPoll) + } + }(m.Offset) +} + +func (c *conn) handleAgentUnfollow(m wire.AgentUnfollow) { + key := agentFollowKey(m.Tool, m.ID) + c.mu.Lock() + done := c.agentFollows[key] + delete(c.agentFollows, key) + c.mu.Unlock() + if done != nil { + close(done) + } +} + // handleAgentSearch answers agentSearch on its own goroutine, for the reason // agentRead gets one with less argument: a search's budget is sixty-four // megabytes of other tools' files. diff --git a/internal/daemon/agents_test.go b/internal/daemon/agents_test.go index 916c1c0..5dcc81e 100644 --- a/internal/daemon/agents_test.go +++ b/internal/daemon/agents_test.go @@ -13,6 +13,7 @@ import ( "github.com/coder/websocket" "github.com/karnstack/flue/internal/agentstore" + "github.com/karnstack/flue/internal/session" "github.com/karnstack/flue/internal/wire" ) @@ -174,6 +175,41 @@ func TestAgentsListsTranscriptsNewestFirst(t *testing.T) { }) } +func TestSessionInfoIsEnrichedWithLiveAgentTranscript(t *testing.T) { + ts, _, srv := newTestServerUI(t, http.NotFoundHandler()) + home := agentFixtureHome(t) + claudePath := filepath.Join(home, ".claude", "projects", "-home-dev-proj", agentClaudeID+".jsonl") + codexPath := filepath.Join(home, ".codex", "sessions", "2026", "07", "01", "rollout-2026-07-01T10-00-00-"+agentCodexID+".jsonl") + piPath := filepath.Join(home, ".pi", "agent", "sessions", "--home-dev-proj--", "2026-03-27T12-35-33_"+agentPiID+".jsonl") + older := time.Now().Add(-2 * time.Minute) + for _, path := range []string{codexPath, piPath} { + if err := os.Chtimes(path, older, older); err != nil { + t.Fatalf("Chtimes older transcript: %v", err) + } + } + newest := time.Now().Add(-time.Minute) + if err := os.Chtimes(claudePath, newest, newest); err != nil { + t.Fatalf("Chtimes claude transcript: %v", err) + } + idx := agentstore.New(t.TempDir(), home) + srv.SetAgentIndex(idx) + c := dial(t, ts) + settleAgents(t, c) + + got := srv.withAgent(session.Info{ + State: "running", Cwd: "/home/dev/proj", CreatedAt: time.Now().Add(-time.Hour), + }) + if got.Agent != "claude" || got.AgentSession != agentClaudeID { + t.Fatalf("withAgent = (%s, %s), want newest claude transcript", got.Agent, got.AgentSession) + } + exited := srv.withAgent(session.Info{ + State: "exited", Cwd: "/home/dev/proj", CreatedAt: time.Now().Add(-time.Hour), + }) + if exited.Agent != "" || exited.AgentSession != "" { + t.Fatalf("exited session was linked: %+v", exited) + } +} + func TestAgentReadPagesForwardAndBackward(t *testing.T) { ts := newAgentTestServer(t) c := dial(t, ts) @@ -235,6 +271,53 @@ func TestAgentReadPagesForwardAndBackward(t *testing.T) { }) } +func TestAgentFollowPushesCompletedMessages(t *testing.T) { + ts, _, srv := newTestServerUI(t, http.NotFoundHandler()) + home := agentFixtureHome(t) + idx := agentstore.New(t.TempDir(), home) + srv.SetAgentIndex(idx) + c := dial(t, ts) + writeControl(t, c, wire.Hello{Ver: "test"}) + settleAgents(t, c) + + path, summary, ok := idx.Resolve(agentstore.ToolClaude, agentClaudeID) + if !ok { + t.Fatal("Resolve missed claude fixture") + } + writeControl(t, c, wire.AgentFollow{ + Tool: "claude", ID: agentClaudeID, Offset: summary.FileSize, + }) + + f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0) + if err != nil { + t.Fatal(err) + } + line := `{"parentUuid":"x","isSidechain":false,"cwd":"/home/dev/proj","sessionId":"` + agentClaudeID + `","type":"assistant","requestId":"follow","message":{"id":"follow","type":"message","role":"assistant","model":"claude-fable-5","content":[{"type":"text","text":"Followed live."}],"usage":{"input_tokens":1,"output_tokens":1}},"uuid":"follow","timestamp":"2026-08-10T09:00:30.000Z"}` + "\n" + if _, err := f.WriteString(line); err != nil { + f.Close() + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + direct, err := idx.ReadPage(agentstore.ToolClaude, agentClaudeID, summary.FileSize, "forward", 100) + if err != nil || len(direct.Messages) != 1 { + t.Fatalf("direct read after append = (%+v, %v), want one message", direct, err) + } + + readUntil(t, c, func(msg any, _ []byte) bool { + page, ok := msg.(wire.AgentPage) + if !ok || page.ReqID != 0 || page.ID != agentClaudeID { + return false + } + if len(page.Messages) != 1 || page.Messages[0].Text != "Followed live." { + t.Errorf("pushed page = %+v", page.Messages) + } + return true + }) + writeControl(t, c, wire.AgentUnfollow{Tool: "claude", ID: agentClaudeID}) +} + func TestAgentSearchFindsAndBounds(t *testing.T) { ts := newAgentTestServer(t) c := dial(t, ts) diff --git a/internal/daemon/conn.go b/internal/daemon/conn.go index 3100406..a560dd3 100644 --- a/internal/daemon/conn.go +++ b/internal/daemon/conn.go @@ -259,6 +259,10 @@ type conn struct { // cancel by name: each goroutine replies once and ends. The cap it // enforces is maxAgentWork; see the comment there. agentWork int + // agentFollows are the live transcript tails this connection asked for. + // Each value is closed to replace or stop that tail; connection teardown + // also cancels the shared context every follower selects on. + agentFollows map[string]chan struct{} // pumps counts the file pumps still running. closeAll waits on it, so no // goroutine holding a file descriptor outlives serve. @@ -272,16 +276,17 @@ type conn struct { func newConn(ctx context.Context, cancel context.CancelFunc, mc MessageConn, srv *Server, peer, origin string, deviceKey []byte) *conn { return &conn{ - ctx: ctx, - cancel: cancel, - mc: mc, - srv: srv, - peer: peer, - origin: origin, - deviceKey: deviceKey, - out: make(chan frame, outboxDepth), - attach: map[uint32]*attachment{}, - reads: map[uint32]*fileRead{}, + ctx: ctx, + cancel: cancel, + mc: mc, + srv: srv, + peer: peer, + origin: origin, + deviceKey: deviceKey, + out: make(chan frame, outboxDepth), + attach: map[uint32]*attachment{}, + reads: map[uint32]*fileRead{}, + agentFollows: map[string]chan struct{}{}, } } @@ -604,7 +609,7 @@ func (c *conn) refsFor(id string) []uint32 { func (c *conn) sendSessions() { infos := []session.Info{} for _, s := range c.srv.reg.List() { - infos = append(infos, s.Info()) + infos = append(infos, c.srv.withAgent(s.Info())) } _ = c.sendControl(wire.Sessions{Sessions: infos}) } @@ -817,6 +822,12 @@ func (c *conn) handleControl(msg any) { case wire.AgentRead: c.handleAgentRead(m) + case wire.AgentFollow: + c.handleAgentFollow(m) + + case wire.AgentUnfollow: + c.handleAgentUnfollow(m) + case wire.AgentSearch: c.handleAgentSearch(m) @@ -1158,6 +1169,17 @@ func (c *conn) closeAll() { for _, ref := range reads { c.endRead(ref) } + + c.mu.Lock() + follows := make([]chan struct{}, 0, len(c.agentFollows)) + for key, done := range c.agentFollows { + delete(c.agentFollows, key) + follows = append(follows, done) + } + c.mu.Unlock() + for _, done := range follows { + close(done) + } // Ending each read first is what makes this wait finite, and it is finite on // its own terms. Every wait a pump can be in selects on that read's done // channel — waiting for outbox room, waiting for the writer to have written diff --git a/internal/daemon/server.go b/internal/daemon/server.go index c257e6d..ea77de3 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -986,7 +986,7 @@ func writeAuthError(w http.ResponseWriter, err error) { func (s *Server) handleSessions(w http.ResponseWriter, r *http.Request) { infos := []session.Info{} for _, sess := range s.reg.List() { - infos = append(infos, sess.Info()) + infos = append(infos, s.withAgent(sess.Info())) } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{"sessions": infos}) diff --git a/internal/session/session.go b/internal/session/session.go index c5f723b..bbcd316 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -105,6 +105,13 @@ type Info struct { // session that carries neither serialises exactly as it always has. Group string `json:"group,omitempty"` Ephemeral bool `json:"ephemeral,omitempty"` + // Agent and AgentSession identify the live coding-agent transcript most + // plausibly owned by this shell. They are enriched by the daemon from the + // transcript index rather than stored on the session: the fallback match + // follows the shell's current cwd and the newest transcript written since + // it was created, so it can move when a shell starts a different agent. + Agent string `json:"agent,omitempty"` + AgentSession string `json:"agentSession,omitempty"` } // MetaPatch is a partial update to a session's human-owned metadata: a nil diff --git a/internal/wire/control.go b/internal/wire/control.go index bf2fccd..54557d2 100644 --- a/internal/wire/control.go +++ b/internal/wire/control.go @@ -341,6 +341,22 @@ type AgentRead struct { ReqID uint64 `json:"reqId,omitempty"` } +// AgentFollow starts a live tail of one transcript at Offset. Completed +// messages appended after that point arrive as unsolicited agentPage frames; +// a second follow for the same (tool, id) replaces the first. AgentUnfollow +// stops it. The stream is connection-scoped and therefore rides the same +// encrypted relay leg as every other session interaction. +type AgentFollow struct { + Tool string `json:"tool"` + ID string `json:"id"` + Offset int64 `json:"offset"` +} + +type AgentUnfollow struct { + Tool string `json:"tool"` + ID string `json:"id"` +} + // AgentSearch asks for the messages matching a query, case-insensitive, // newest sessions first. Tools and Cwd filter as on Agents; Limit caps the // hits. Answered by agentHits. @@ -758,6 +774,10 @@ func typeName(msg any) (string, bool) { return "agents", true case AgentRead: return "agentRead", true + case AgentFollow: + return "agentFollow", true + case AgentUnfollow: + return "agentUnfollow", true case AgentSearch: return "agentSearch", true case Welcome: @@ -874,6 +894,10 @@ func DecodeControl(b []byte) (any, error) { return *t, nil case *AgentRead: return *t, nil + case *AgentFollow: + return *t, nil + case *AgentUnfollow: + return *t, nil case *AgentSearch: return *t, nil case *Welcome: @@ -973,6 +997,10 @@ func DecodeControl(b []byte) (any, error) { return deref(into(&Agents{})) case "agentRead": return deref(into(&AgentRead{})) + case "agentFollow": + return deref(into(&AgentFollow{})) + case "agentUnfollow": + return deref(into(&AgentUnfollow{})) case "agentSearch": return deref(into(&AgentSearch{})) case "agentIndex": diff --git a/internal/wire/wire_test.go b/internal/wire/wire_test.go index 8dc0009..638cb6e 100644 --- a/internal/wire/wire_test.go +++ b/internal/wire/wire_test.go @@ -690,6 +690,25 @@ func TestAgentRepliesEncodeEmptyListsAsArrays(t *testing.T) { } } +func TestAgentFollowMessagesRoundTrip(t *testing.T) { + for _, want := range []any{ + AgentFollow{Tool: "codex", ID: "thread-1", Offset: 42}, + AgentUnfollow{Tool: "codex", ID: "thread-1"}, + } { + b, err := EncodeControl(want) + if err != nil { + t.Fatal(err) + } + got, err := DecodeControl(b) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("round trip = %#v, want %#v", got, want) + } + } +} + // deepEqual recursively compares two any values for equality, handling // nested maps and slices. Used by TestGoldenControlMessages to verify // round-trip fidelity while tolerating type differences between JSON diff --git a/web/src/agents/prompt.test.ts b/web/src/agents/prompt.test.ts new file mode 100644 index 0000000..89c1e9a --- /dev/null +++ b/web/src/agents/prompt.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' + +import { parseTerminalPrompt, terminalNeedsAttention, terminalPlainText } from './prompt' + +describe('terminal prompt fallback', () => { + it('turns a numbered question into safe inputs', () => { + expect( + parseTerminalPrompt('\x1b[1mHow should I continue?\x1b[0m\r\n 1. Apply the change\r\n❯ 2. Cancel'), + ).toEqual({ + question: 'How should I continue?', + options: [ + { label: 'Apply the change', input: '1' }, + { label: 'Cancel', input: '2' }, + ], + }) + }) + + it('recognises yes/no prompts', () => { + expect(parseTerminalPrompt('Allow this command? [y/N]')).toEqual({ + question: 'Allow this command?', + options: [ + { label: 'Yes', input: 'y' }, + { label: 'No', input: 'n' }, + ], + }) + }) + + it('refuses unrelated numbered output', () => { + expect(parseTerminalPrompt('Files changed\n1. app.ts\n2. app.test.ts')).toBeNull() + }) + + it('strips controls and spots an unrenderable permission prompt', () => { + expect(terminalPlainText('\x1b[31mApprove command?\x1b[0m')).toBe('Approve command?') + expect(terminalNeedsAttention('Permission required: approve in the terminal?')).toBe(true) + }) +}) diff --git a/web/src/agents/prompt.ts b/web/src/agents/prompt.ts new file mode 100644 index 0000000..4dbbd32 --- /dev/null +++ b/web/src/agents/prompt.ts @@ -0,0 +1,68 @@ +export interface TerminalPromptOption { + label: string + input: string +} + +export interface TerminalPrompt { + question: string + options: TerminalPromptOption[] +} + +/** Flatten the terminal control sequences that can occur around a prompt. */ +export function terminalPlainText(raw: string): string { + return raw + .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, '') + .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '') + .replace(/\x1b[()][A-Z0-9]/g, '') + .replace(/[^\S\n]*\r/g, '\n') + .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '') +} + +/** + * Parse the small prompt shapes shared by coding-agent TUIs. Exact hook + * payloads can replace this when available; this fallback intentionally + * refuses ambiguous prose rather than turning arbitrary numbered output into + * buttons that type into a live shell. + */ +export function parseTerminalPrompt(raw: string): TerminalPrompt | null { + const lines = terminalPlainText(raw) + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .slice(-14) + + const last = lines.at(-1) ?? '' + const yesNo = last.match(/^(.*?)(?:\s+)?\[([yYnN])\/([yYnN])\]\s*$/) + if (yesNo !== null) { + const question = yesNo[1]?.trim() || 'Continue?' + return { + question, + options: [ + { label: 'Yes', input: 'y' }, + { label: 'No', input: 'n' }, + ], + } + } + + const options: TerminalPromptOption[] = [] + let firstOption = -1 + for (let index = 0; index < lines.length; index++) { + const match = lines[index]!.match(/^(?:[>›❯]\s*)?(\d{1,2})[.)]\s+(.{1,120})$/) + if (match === null) continue + firstOption = firstOption < 0 ? index : firstOption + options.push({ label: match[2]!.trim(), input: match[1]! }) + } + if (options.length < 2 || firstOption < 1 || options.length > 8) return null + + const before = lines.slice(Math.max(0, firstOption - 4), firstOption) + const question = [...before].reverse().find((line) => /[?:]$/.test(line)) + if (question === undefined) return null + return { question, options } +} + +export function terminalNeedsAttention(raw: string): boolean { + const tail = terminalPlainText(raw).split('\n').slice(-8).join(' ') + return /(?:allow|approve|permission|continue|proceed|choose|select).{0,80}(?:\?|\[y\/n\])/i.test( + tail, + ) +} diff --git a/web/src/client/client.test.ts b/web/src/client/client.test.ts index 0139eff..bb498d4 100644 --- a/web/src/client/client.test.ts +++ b/web/src/client/client.test.ts @@ -3399,6 +3399,37 @@ describe('FlueClient agent transcripts', () => { await answer }) + it('streams pushed pages and resumes the advanced offset after reconnect', async () => { + vi.useFakeTimers() + vi.spyOn(Math, 'random').mockReturnValue(0) + const { c, sock, sockets } = connected() + const pushed: AgentPageMsg[] = [] + c.onAgentPage((message) => pushed.push(message)) + + c.followAgent('claude', 'a1', 20) + expect(sock.sentControl().filter((m) => m.type === 'agentFollow')).toStrictEqual([ + { type: 'agentFollow', tool: 'claude', id: 'a1', offset: 20 }, + ]) + const event: AgentPageMsg = { + type: 'agentPage', tool: 'claude', id: 'a1', messages: [], + start: 0, next: 44, eof: true, fileSize: 44, + } + sock.emitControl(event) + expect(pushed).toStrictEqual([event]) + + sock.close() + await vi.advanceTimersByTimeAsync(125) + sockets[1]!.open() + expect(sockets[1]!.sentControl().filter((m) => m.type === 'agentFollow')).toStrictEqual([ + { type: 'agentFollow', tool: 'claude', id: 'a1', offset: 44 }, + ]) + + c.unfollowAgent('claude', 'a1') + expect(sockets[1]!.sentControl().at(-1)).toStrictEqual({ + type: 'agentUnfollow', tool: 'claude', id: 'a1', + }) + }) + it('resolves agentSearch with the hits that echo its reqId', async () => { const { c, sock } = connected() diff --git a/web/src/client/client.ts b/web/src/client/client.ts index a15e559..aea411e 100644 --- a/web/src/client/client.ts +++ b/web/src/client/client.ts @@ -348,6 +348,9 @@ export class FlueClient { private agentPageAsks = new Map>() private agentHitsAsks = new Map>() + /** Live transcript tails, kept across reconnects like PTY attachments. */ + private agentFollows = new Map() + /** A `list` asked for while the socket was down. See `list`. */ private listOwed = false @@ -370,6 +373,7 @@ export class FlueClient { private pairingListeners = new Emitter<[Pairing]>() private revokedListeners = new Emitter<[string]>() private welcomeListeners = new Emitter<[Welcome]>() + private agentPageListeners = new Emitter<[AgentPageMsg]>() /** * The last welcome this client was handed, kept across reconnects rather @@ -417,6 +421,9 @@ export class FlueClient { onStatus(cb: (s: ConnStatus) => void) { return this.statusListeners.add(cb) } + onAgentPage(cb: (page: AgentPageMsg) => void) { + return this.agentPageListeners.add(cb) + } /** * A session the daemon answered `not_found` for. The client has already @@ -838,6 +845,24 @@ export class FlueClient { })) } + /** + * Keep receiving completed messages appended after `offset`. The daemon + * pushes ordinary agentPage frames with no reqId; the intent survives a + * reconnect and advances as pages arrive, so a brief outage neither drops + * nor duplicates the tail. + */ + followAgent(tool: AgentTool, id: string, offset: number) { + const key = `${tool}\u0000${id}` + const follow = { tool, id, offset } + this.agentFollows.set(key, follow) + this.send({ type: 'agentFollow', ...follow }) + } + + unfollowAgent(tool: AgentTool, id: string) { + this.agentFollows.delete(`${tool}\u0000${id}`) + this.send({ type: 'agentUnfollow', tool, id }) + } + /** Search the indexed transcripts, narrowed and capped as the caller asks. */ agentSearch( query: string, @@ -1106,6 +1131,9 @@ export class FlueClient { this.send({ type: 'hello', ver: PROTOCOL_VERSION, caps: [...CAPS] }) for (const [id, lastSeq] of this.wanted) this.sendAttach(id, lastSeq) + for (const follow of this.agentFollows.values()) { + this.send({ type: 'agentFollow', ...follow }) + } if (this.listOwed) { this.listOwed = false this.send({ type: 'list' }) @@ -1291,7 +1319,16 @@ export class FlueClient { break case 'agentPage': - this.settleAgentAsk(this.agentPageAsks, msg) + if (msg.reqId !== undefined) { + this.settleAgentAsk(this.agentPageAsks, msg as AgentPageMsg & { reqId: number }) + break + } + { + const follow = this.agentFollows.get(`${msg.tool}\u0000${msg.id}`) + if (follow === undefined) break + follow.offset = msg.next + this.agentPageListeners.emit(msg) + } break case 'agentHits': diff --git a/web/src/client/protocol.ts b/web/src/client/protocol.ts index 4268c0f..7c29813 100644 --- a/web/src/client/protocol.ts +++ b/web/src/client/protocol.ts @@ -107,6 +107,10 @@ export interface SessionInfo { * its UI detaches — the shell keeps running until then. Absent means false. */ ephemeral?: boolean + /** Coding-agent transcript currently linked to this live shell. */ + agent?: AgentTool + /** Transcript id addressable by `agentRead`. */ + agentSession?: string } /** @@ -403,6 +407,19 @@ export interface AgentReadMsg { reqId: number } +export interface AgentFollowMsg { + type: 'agentFollow' + tool: AgentTool + id: string + offset: number +} + +export interface AgentUnfollowMsg { + type: 'agentUnfollow' + tool: AgentTool + id: string +} + /** * Search every indexed transcript for a substring, case-insensitively. * Answered by `agentHits` echoing `reqId`. The filters are `agents`'s two, @@ -438,6 +455,8 @@ export type ClientMessage = | PairCancelMsg | AgentsMsg | AgentReadMsg + | AgentFollowMsg + | AgentUnfollowMsg | AgentSearchMsg // Server -> client. @@ -878,7 +897,8 @@ export interface AgentPageMsg { eof: boolean /** The file's size at parse time — where a jump-to-end reads back from. */ fileSize: number - reqId: number + /** Present on an `agentRead` answer; absent on a live-follow push. */ + reqId?: number } /** diff --git a/web/src/components/agent-chat.test.tsx b/web/src/components/agent-chat.test.tsx new file mode 100644 index 0000000..6065d41 --- /dev/null +++ b/web/src/components/agent-chat.test.tsx @@ -0,0 +1,61 @@ +import { act, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it } from 'vitest' + +import { FlueClientProvider } from '@/client/provider' +import { attached, fakeClient } from '@/testing/socket' +import { AgentChat } from './agent-chat' + +describe('AgentChat', () => { + it('writes messages and fallback prompt choices to the attached PTY', async () => { + const user = userEvent.setup() + const { client, sockets } = fakeClient() + const view = render({null}) + + act(() => sockets[0]!.open()) + view.rerender( + + {}} + /> + , + ) + + const socket = sockets[0]! + const attach = socket.ofType('attach')[0]! + const read = socket.ofType('agentRead')[0]! + await act(async () => { + socket.emitControl(attached({ + id: 'shell-1', + ref: 7, + reqId: attach.reqId as number, + })) + socket.emitControl({ + type: 'agentPage', + tool: 'claude', + id: 'agent-1', + messages: [], + start: 0, + next: 0, + eof: true, + fileSize: 0, + reqId: read.reqId, + }) + }) + + const composer = screen.getByRole('textbox', { name: 'Message Claude' }) + await user.type(composer, 'Ship it') + await user.click(screen.getByRole('button', { name: 'Send message' })) + expect(socket.input()).toContainEqual({ ref: 7, text: 'Ship it\r' }) + + await user.click(screen.getByRole('button', { name: 'Interrupt' })) + expect(socket.input()).toContainEqual({ ref: 7, text: '\x1b' }) + + act(() => socket.emitOutput(7, 'Allow command? [y/N]')) + await user.click(await screen.findByRole('button', { name: 'Yes' })) + expect(socket.input()).toContainEqual({ ref: 7, text: 'y\r' }) + }) +}) diff --git a/web/src/components/agent-chat.tsx b/web/src/components/agent-chat.tsx new file mode 100644 index 0000000..1404bd9 --- /dev/null +++ b/web/src/components/agent-chat.tsx @@ -0,0 +1,465 @@ +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type CSSProperties, + type FormEvent, + type KeyboardEvent, +} from 'react' +import { useVirtualizer } from '@tanstack/react-virtual' +import { + ArrowDownIcon, + CommandLineIcon, + PaperAirplaneIcon, + StopIcon, +} from '@heroicons/react/16/solid' + +import { keyMessages, type KeyedAgentMessage } from '@/agents/view' +import { + parseTerminalPrompt, + terminalNeedsAttention, + type TerminalPrompt, +} from '@/agents/prompt' +import type { AgentMessage, AgentTool } from '@/client/protocol' +import { useFlueClient } from '@/client/provider' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' +import { AgentMessageBlock } from './agent-message' + +const PAGE_LIMIT = 100 +const NEAR_BOTTOM_PX = 80 +const ACTIVITY_QUIET_MS = 1_400 +const TOOL_LABEL: Record = { claude: 'Claude', codex: 'Codex', pi: 'Pi' } + +export type AgentSessionView = 'chat' | 'terminal' + +export function AgentViewBar({ + tool, + view, + onView, +}: { + tool: AgentTool + view: AgentSessionView + onView(next: AgentSessionView): void +}) { + return ( +
+
+
+
+ {(['chat', 'terminal'] as const).map((item) => ( + + ))} +
+
+ ) +} + +interface PendingMessage { + id: number + text: string + queued: boolean +} + +export function AgentChat({ + sessionId, + tool, + transcriptId, + onTerminal, +}: { + sessionId: string + tool: AgentTool + transcriptId: string + onTerminal(): void +}) { + const client = useFlueClient() + const [messages, setMessages] = useState([]) + const [start, setStart] = useState(0) + const [phase, setPhase] = useState<'loading' | 'ready' | 'failed' | 'exited'>('loading') + const [loadingOlder, setLoadingOlder] = useState(false) + const [expanded, setExpanded] = useState>(() => new Set()) + const [draft, setDraft] = useState('') + const [pending, setPending] = useState([]) + const [working, setWorking] = useState(false) + const [newBelow, setNewBelow] = useState(false) + const [prompt, setPrompt] = useState(null) + const [needsTerminal, setNeedsTerminal] = useState(false) + const [ref, setRef] = useState(null) + const refRef = useRef(null) + const replayHead = useRef(0) + const generation = useRef(0) + const activityTimer = useRef | null>(null) + const terminalTail = useRef('') + const decoder = useRef(new TextDecoder()) + const nextPending = useRef(1) + const scrollRef = useRef(null) + const stickBottom = useRef(true) + + const virtualizer = useVirtualizer({ + count: messages.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => 72, + overscan: 8, + }) + + const scrollToEnd = useCallback(() => { + if (messages.length === 0) return + virtualizer.scrollToIndex(messages.length - 1, { align: 'end' }) + }, [messages.length, virtualizer]) + + const receiveMessages = useCallback((incoming: AgentMessage[]) => { + if (incoming.length === 0) return + const keyed = keyMessages(incoming) + setPending((current) => { + const next = [...current] + for (const message of incoming) { + if (message.role !== 'user') continue + const at = next.findIndex((item) => item.text.trim() === message.text.trim()) + if (at >= 0) next.splice(at, 1) + } + return next + }) + setMessages((current) => { + const seen = new Set(current.map((message) => message.key)) + const fresh = keyed.filter((message) => !seen.has(message.key)) + return fresh.length === 0 ? current : [...current, ...fresh] + }) + }, []) + + useEffect(() => { + const mine = ++generation.current + let attachedRef: number | null = null + const offs = [ + client.onAttached((attached) => { + if (attached.id !== sessionId) return + attachedRef = attached.ref + refRef.current = attached.ref + replayHead.current = attached.head + setRef(attached.ref) + }), + client.onOutput((outputRef, bytes) => { + if (outputRef !== refRef.current) return + const nextSeq = client.lastSeqFor(outputRef) ?? 0 + if (nextSeq <= replayHead.current) return + terminalTail.current = (terminalTail.current + decoder.current.decode(bytes, { stream: true })).slice( + -48_000, + ) + const parsed = parseTerminalPrompt(terminalTail.current) + setPrompt(parsed) + setNeedsTerminal(parsed === null && terminalNeedsAttention(terminalTail.current)) + setWorking(true) + if (activityTimer.current !== null) clearTimeout(activityTimer.current) + activityTimer.current = setTimeout(() => setWorking(false), ACTIVITY_QUIET_MS) + }), + client.onAgentPage((page) => { + if (page.tool !== tool || page.id !== transcriptId) return + const el = scrollRef.current + const atBottom = + el === null || el.scrollHeight - el.scrollTop - el.clientHeight < NEAR_BOTTOM_PX + stickBottom.current = atBottom + if (!atBottom) setNewBelow(true) + receiveMessages(page.messages) + }), + client.onExit((exitRef) => { + if (exitRef === refRef.current) setPhase('exited') + }), + ] + + client.attach(sessionId, 0) + client.agentRead(tool, transcriptId, Number.MAX_SAFE_INTEGER, { dir: 'backward', limit: PAGE_LIMIT }).then( + (page) => { + if (generation.current !== mine) return + setMessages(keyMessages(page.messages)) + setStart(page.start) + setPhase('ready') + stickBottom.current = true + client.followAgent(tool, transcriptId, page.next) + }, + () => { + if (generation.current === mine) setPhase('failed') + }, + ) + + return () => { + generation.current++ + for (const off of offs) off() + client.unfollowAgent(tool, transcriptId) + if (activityTimer.current !== null) clearTimeout(activityTimer.current) + if (attachedRef !== null) client.detach(attachedRef) + else client.forget(sessionId) + refRef.current = null + } + }, [client, receiveMessages, sessionId, tool, transcriptId]) + + useLayoutEffect(() => { + if (!stickBottom.current || messages.length === 0) return + const frame = requestAnimationFrame(scrollToEnd) + return () => cancelAnimationFrame(frame) + }, [messages.length, scrollToEnd]) + + const loadOlder = () => { + if (start <= 0 || loadingOlder) return + setLoadingOlder(true) + const beforeHeight = scrollRef.current?.scrollHeight ?? 0 + const beforeTop = scrollRef.current?.scrollTop ?? 0 + client.agentRead(tool, transcriptId, start, { dir: 'backward', limit: PAGE_LIMIT }).then( + (page) => { + setLoadingOlder(false) + setStart(page.start) + setMessages((current) => [...keyMessages(page.messages), ...current]) + requestAnimationFrame(() => { + const el = scrollRef.current + if (el !== null) el.scrollTop = beforeTop + el.scrollHeight - beforeHeight + }) + }, + () => setLoadingOlder(false), + ) + } + + const sendRaw = useCallback( + (text: string) => { + const activeRef = refRef.current + if (activeRef === null) return false + client.sendInput(activeRef, new TextEncoder().encode(`${text}\r`)) + terminalTail.current = '' + setPrompt(null) + setNeedsTerminal(false) + return true + }, + [client], + ) + + const submit = (event?: FormEvent) => { + event?.preventDefault() + const text = draft.trim() + if (text === '' || phase === 'exited' || !sendRaw(text)) return + setPending((current) => [ + ...current, + { id: nextPending.current++, text, queued: working }, + ]) + setDraft('') + stickBottom.current = true + } + + const interrupt = () => { + const activeRef = refRef.current + if (activeRef === null) return + client.sendInput(activeRef, new Uint8Array([0x1b])) + } + + const onComposerKey = (event: KeyboardEvent) => { + if (event.key !== 'Enter' || event.shiftKey || event.nativeEvent.isComposing) return + event.preventDefault() + submit() + } + + const status = useMemo(() => { + if (phase === 'exited') return 'Session ended' + if (ref === null) return 'Connecting' + if (working) return `${TOOL_LABEL[tool]} is working` + return 'Ready for your message' + }, [phase, ref, tool, working]) + + return ( +
+
{ + const el = event.currentTarget + stickBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < NEAR_BOTTOM_PX + if (stickBottom.current) setNewBelow(false) + }} + className="min-h-0 flex-1 overflow-y-auto overscroll-y-contain" + > +
+ {phase === 'loading' ? ( +
+ +

+ Opening the conversation. +

+
+ ) : phase === 'failed' ? ( +
+

+ The transcript could not be read right now. +

+ +
+ ) : ( + <> + {start > 0 && ( +
+ +
+ )} + {messages.length === 0 && ( +
+
+ )} +
+ {virtualizer.getVirtualItems().map((item) => { + const message = messages[item.index]! + return ( +
+ 0 ? messages[item.index - 1] : undefined} + open={expanded.has(message.key)} + onToggle={() => + setExpanded((current) => { + const next = new Set(current) + if (!next.delete(message.key)) next.add(message.key) + return next + }) + } + now={Date.now()} + /> +
+ ) + })} +
+ {pending.map((message) => ( +
+
+

You

+

+ {message.text} +

+

{message.queued ? 'Queued' : 'Sending'}

+
+
+ ))} + {working && ( +
+
+ )} + + )} +
+
+ + {newBelow && ( + + )} + +
+
+ {prompt !== null && ( +
+

{prompt.question}

+
+ {prompt.options.map((option) => ( + + ))} +
+
+ )} + {needsTerminal && prompt === null && ( + + )} +
+ + {['/compact', '/clear'].map((command) => ( + + ))} +
+
+