diff --git a/cmd/reminal/hook.go b/cmd/reminal/hook.go index 48f2a0a..545a3a2 100644 --- a/cmd/reminal/hook.go +++ b/cmd/reminal/hook.go @@ -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 } diff --git a/cmd/reminal/hook_conv_test.go b/cmd/reminal/hook_conv_test.go index da36ea7..b233548 100644 --- a/cmd/reminal/hook_conv_test.go +++ b/cmd/reminal/hook_conv_test.go @@ -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"}`: "", diff --git a/cmd/reminal/integrate.go b/cmd/reminal/integrate.go index e65d898..44639a8 100644 --- a/cmd/reminal/integrate.go +++ b/cmd/reminal/integrate.go @@ -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. @@ -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) @@ -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 } } @@ -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 }, }, }, @@ -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 }, }, }, @@ -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"}, }, }, }, @@ -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"}, }, }, }, diff --git a/cmd/reminal/integrate_hooks_test.go b/cmd/reminal/integrate_hooks_test.go index 8e4551a..a306970 100644 --- a/cmd/reminal/integrate_hooks_test.go +++ b/cmd/reminal/integrate_hooks_test.go @@ -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) + } +} diff --git a/internal/client/agent.go b/internal/client/agent.go index fff3fba..b32bf10 100644 --- a/internal/client/agent.go +++ b/internal/client/agent.go @@ -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 @@ -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) ; (BEL | ESC \)". Most shells set this to the cwd or the diff --git a/internal/client/markinput_test.go b/internal/client/markinput_test.go new file mode 100644 index 0000000..68fb3f4 --- /dev/null +++ b/internal/client/markinput_test.go @@ -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") + } +} diff --git a/internal/client/restore.go b/internal/client/restore.go index 2f52df2..f169a34 100644 --- a/internal/client/restore.go +++ b/internal/client/restore.go @@ -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() diff --git a/internal/client/windows.go b/internal/client/windows.go index 18e43b1..c84c8b2 100644 --- a/internal/client/windows.go +++ b/internal/client/windows.go @@ -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