Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/reminal/hook.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ func convFromPayload(b []byte) string {
if json.Unmarshal(b, &m) != nil {
return ""
}
for _, k := range []string{"session_id", "conversation_id", "thread_id", "thread-id", "chat_id"} {
for _, k := range []string{"session_id", "conversation_id", "conversationId", "thread_id", "thread-id", "chat_id"} {
if v, ok := m[k].(string); ok && convIDRe.MatchString(v) {
return v
}
Expand Down
1 change: 1 addition & 0 deletions cmd/reminal/hook_conv_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ func TestConvFromPayload(t *testing.T) {
cases := map[string]string{
`{"session_id":"3f2c9a10-7d4e-4b8a-9c11-0e5d6f7a8b9c","hook_event_name":"Stop"}`: "3f2c9a10-7d4e-4b8a-9c11-0e5d6f7a8b9c",
`{"conversation_id":"chat_0123456789"}`: "chat_0123456789",
`{"conversationId":"agy_conversation_0042"}`: "agy_conversation_0042",
`{"session_id":"x; rm -rf ~"}`: "",
`{"session_id":"$(curl evil)"}`: "",
`{"session_id":"short"}`: "",
Expand Down
43 changes: 25 additions & 18 deletions cmd/reminal/integrate.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ const (
type hookEvent struct {
Event string // the agent's own event name, e.g. "Notification"
State string // our state it maps to: "working" | "input" | "done"
// Matcher narrows a tool event to the tools named (shapeMatcher only);
// empty, the entry carries no matcher at all.
Matcher string
}

// hookSpec is how to install reminal's attention hooks into one agent's config.
Expand Down Expand Up @@ -212,7 +215,7 @@ func applyHooks(spec *hookSpec, home, exe string, remove bool) error {
kept = append(kept, item)
}
if !remove {
kept = append(kept, hookEntry(spec.shape, exe, ev.State))
kept = append(kept, hookEntry(spec.shape, exe, ev))
}
if len(kept) == 0 {
delete(node, ev.Event)
Expand Down Expand Up @@ -304,16 +307,20 @@ func ourHookEntry(m map[string]any) bool {

// hookEntry builds one config entry in the agent's expected shape, tagged with
// hookMarker so we can find and remove exactly our own on a re-run or --remove.
func hookEntry(shape hookShape, exe, state string) map[string]any {
cmd := hookCommand(exe, state)
func hookEntry(shape hookShape, exe string, ev hookEvent) map[string]any {
cmd := hookCommand(exe, ev.State)
switch shape {
case shapeFlat:
return map[string]any{"command": cmd, hookMarker: state}
return map[string]any{"command": cmd, hookMarker: ev.State}
default: // shapeMatcher
return map[string]any{
e := map[string]any{
"hooks": []any{map[string]any{"type": "command", "command": cmd}},
hookMarker: state,
hookMarker: ev.State,
}
if ev.Matcher != "" {
e["matcher"] = ev.Matcher
}
return e
}
}

Expand All @@ -336,10 +343,10 @@ func agentTargets() []agentTarget {
hooks: &hookSpec{
file: ".claude/settings.json", key: []string{"hooks"}, shape: shapeMatcher,
events: []hookEvent{
{"UserPromptSubmit", "working"}, // a turn begins
{"PermissionRequest", "input"}, // blocked on your approval — the precise event
{"Notification", "notify"}, // idle (or, on older builds, permission) — split by payload
{"Stop", "done"}, // turn finished
{Event: "UserPromptSubmit", State: "working"}, // a turn begins
{Event: "PermissionRequest", State: "input"}, // blocked on your approval — the precise event
{Event: "Notification", State: "notify"}, // idle (or, on older builds, permission) — split by payload
{Event: "Stop", State: "done"}, // turn finished
},
},
},
Expand All @@ -362,8 +369,8 @@ func agentTargets() []agentTarget {
hooks: &hookSpec{
file: ".gemini/config/hooks.json", key: []string{mcpServerName}, shape: shapeFlat, cleanTop: true,
events: []hookEvent{
{"PreInvocation", "working"}, // a turn begins
{"Stop", "done"}, // turn finished
{Event: "PreInvocation", State: "working"}, // a turn begins
{Event: "Stop", State: "done"}, // turn finished
},
},
},
Expand All @@ -388,9 +395,9 @@ func agentTargets() []agentTarget {
hooks: &hookSpec{
file: ".gemini/settings.json", key: []string{"hooks"}, shape: shapeMatcher,
events: []hookEvent{
{"BeforeAgent", "working"},
{"Notification", "input"}, // observability-only; fires on tool-permission
{"AfterAgent", "done"},
{Event: "BeforeAgent", State: "working"},
{Event: "Notification", State: "input"}, // observability-only; fires on tool-permission
{Event: "AfterAgent", State: "done"},
},
},
},
Expand All @@ -401,9 +408,9 @@ func agentTargets() []agentTarget {
hooks: &hookSpec{
file: ".qwen/settings.json", key: []string{"hooks"}, shape: shapeMatcher,
events: []hookEvent{
{"UserPromptSubmit", "working"},
{"Notification", "notify"}, // idle_prompt + permission_prompt — split by payload
{"Stop", "done"},
{Event: "UserPromptSubmit", State: "working"},
{Event: "Notification", State: "notify"}, // idle_prompt + permission_prompt — split by payload
{Event: "Stop", State: "done"},
},
},
},
Expand Down
38 changes: 38 additions & 0 deletions cmd/reminal/integrate_hooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,3 +169,41 @@ func TestAntigravityHooksUnderOurName(t *testing.T) {
t.Errorf("after --remove: %s", b)
}
}

// An entry is written with a matcher only when its event names one: every
// event in the table writes none today, exactly as before the table carried
// matchers, and one that names tools gets them — and is still replaced, not
// added again, on a re-run.
func TestHookEntryMatcher(t *testing.T) {
exe := "/usr/local/bin/reminal"
for _, tg := range agentTargets() {
if tg.hooks == nil {
continue
}
for _, ev := range tg.hooks.events {
if _, has := hookEntry(tg.hooks.shape, exe, ev)["matcher"]; has {
t.Errorf("%s %s: entry carries a matcher", tg.Name, ev.Event)
}
}
}

home := t.TempDir()
spec := &hookSpec{file: "s.json", key: []string{"hooks"}, shape: shapeMatcher,
events: []hookEvent{{Event: "PostToolUse", State: "working", Matcher: "Bash|Edit"}}}
for i := 0; i < 2; i++ {
if err := applyHooks(spec, home, exe, false); err != nil {
t.Fatal(err)
}
}
b, _ := os.ReadFile(filepath.Join(home, spec.file))
var root struct {
Hooks map[string][]map[string]any `json:"hooks"`
}
if err := json.Unmarshal(b, &root); err != nil {
t.Fatal(err)
}
list := root.Hooks["PostToolUse"]
if len(list) != 1 || list[0]["matcher"] != "Bash|Edit" {
t.Errorf("want one entry with its matcher, got %s", b)
}
}
23 changes: 20 additions & 3 deletions internal/client/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,13 +123,16 @@ type Agent struct {
restoring bool
// restoreAgentSeen: since this restore, the agent has been seen running
// again — so a prompt now means it was quit, not that it is still coming
// back. Touched only by saveRestore, on the restore loop's goroutine.
// back. Touched only by saveRestore, under restoreMu.
restoreAgentSeen bool
restoreRun string
restoreNote string
restorePlan func() (run, note string)
restoreSeq uint64
stopSignal atomic.Bool
// restoreMu makes saveRestore one at a time, so two saves can never
// interleave their reads and writes of the restore state.
restoreMu sync.Mutex

// screen is a headless terminal emulator fed the same plaintext output
// that goes to viewers. On a fresh attach we serialize its current state
Expand Down Expand Up @@ -1413,14 +1416,28 @@ func (a *Agent) noteInput(data []byte) {
}

// markInput stamps when a person last typed, for the record; throttled to
// disk like markActivity.
// disk like markActivity — except the first input after a pause, written at
// once: whatever reads the record (a list of who is at which session) hears
// that someone came back now, not at the next flush.
func (a *Agent) markInput() {
now := time.Now()
a.metaMu.Lock()
a.lastInput = time.Now()
back := now.Sub(a.lastInput) >= inputFlushAfter
a.lastInput = now
a.metaMu.Unlock()
a.metaDirty.Store(true)
if back {
select {
case a.metaKick <- struct{}{}:
default:
}
}
}

// inputFlushAfter is the pause after which input is written to the record at
// once (markInput).
const inputFlushAfter = 2 * time.Second

// feedTitle drives a tiny state machine over PTY output to capture the latest
// terminal title the shell sets via OSC 0 (icon+title) or OSC 2 (title) —
// "ESC ] (0|2) ; <text> (BEL | ESC \)". Most shells set this to the cwd or the
Expand Down
42 changes: 42 additions & 0 deletions internal/client/markinput_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Harshal Gajjar

package client

import (
"testing"
"time"
)

// The first input after a pause is written to the record at once — whatever
// decides by it must hear that someone came back now — and the rest of a
// burst waits for the usual flush.
func TestFirstInputAfterAPauseFlushesAtOnce(t *testing.T) {
a := &Agent{metaKick: make(chan struct{}, 1)}
kicked := func() bool {
select {
case <-a.metaKick:
return true
default:
return false
}
}
a.markInput()
if !kicked() {
t.Fatal("the first input did not ask for a flush")
}
a.markInput()
if kicked() {
t.Fatal("input in the same burst asked for another flush")
}
a.metaMu.Lock()
a.lastInput = time.Now().Add(-inputFlushAfter)
a.metaMu.Unlock()
a.markInput()
if !kicked() {
t.Fatal("input after a pause did not ask for a flush")
}
if !a.metaDirty.Load() {
t.Fatal("the record was not marked dirty")
}
}
2 changes: 2 additions & 0 deletions internal/client/restore.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ func (a *Agent) saveRestore() {
if a == nil || a.term == nil || a.paused.Load() || a.sessionID == "" {
return
}
a.restoreMu.Lock()
defer a.restoreMu.Unlock()
a.metaMu.Lock()
name, cwd := a.name, a.cwd
a.metaMu.Unlock()
Expand Down
3 changes: 3 additions & 0 deletions internal/client/windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,9 @@ func (a *Agent) handleWindowInput(encData string) {
// input channel (onRTCInput) so both roads inject identically — the only
// difference is that P2P skips the relay hop and the decrypt.
func (a *Agent) applyInputPayload(plaintext []byte) {
// A click or a key in a window is a person at work, as typing in the
// terminal is: the session's record says so (session.Active.LastInput).
a.markInput()
if runtime.GOOS == "darwin" {
// Inject in the daemon (sh.reminal) so one grant covers Accessibility +
// Automation for every session, terminal or "+". Runs on the serialized
Expand Down
Loading