1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
//! The integration panel: which agents are hooked up, and where.
//!
//! Installing a hook edits a settings file that belongs to another program, so
//! this is deliberately separate from [`signals`](super::signals), which reads
//! what those hooks send. The panel answers a different question — not "what is
//! this agent doing" but "would it be able to tell me at all" — and it is the
//! only place that writes to another harness's configuration.
use super::*;
impl App {
/// Open the integration panel, reading the current state off disk.
pub fn open_hooks(&mut self) {
self.hooks = Some(self.hook_status());
self.mode = Mode::Hooks;
self.needs_redraw = true;
}
/// The integration's state, scoped to whichever project the cursor is on.
pub(super) fn hook_status(&self) -> crate::hook::Report {
let mut report =
crate::hook::status(self.hook_project().as_deref(), self.listener.as_ref());
// Codex's entry carries a reminder to go and trust its hooks, which is
// advice until it is done and noise afterwards. Only the events can tell
// which — see [`App::codex_hooks_heard`] — so the panel is where the
// reminder is dropped rather than where it is written.
if self.codex_hooks_heard() {
for entry in &mut report.entries {
if entry.harness == crate::hook::Harness::Codex {
entry.note = None;
}
}
}
report
}
/// Whether Codex's hooks have ever fired into this cctop.
///
/// Codex is the one harness whose hooks sit there inert until a person has
/// reviewed and trusted them, and where that trust is recorded is not
/// something Codex documents — so an install that reads as complete on disk
/// may be delivering nothing at all.
///
/// What answers it is the permission mode. `notify`, Codex's other channel,
/// carries no such field, and a hook carries it on every event worth having
/// — so a Codex row that knows how much it asks before it acts is a Codex
/// whose hooks are firing. Rows from another machine are excluded: their
/// mode arrived over `--host` from a cctop where the hooks work, which says
/// nothing about this one.
pub(super) fn codex_hooks_heard(&self) -> bool {
self.sessions.iter().any(|session| {
session.provider == Provider::Codex
&& session.remote.is_none()
&& session.permission.is_some()
})
}
/// The line a Codex being started needs, when it needs one.
///
/// The moment of starting one is when this is worth saying and cheap to act
/// on: there is a fresh prompt on screen, `/hooks` costs a keystroke there,
/// and the alternative is a session that runs for an hour reporting only
/// that its turns ended. Said only when the hooks are installed and have
/// never been heard from — an install nobody asked for is not something to
/// nag about, and one that is already working needs nothing.
pub(super) fn codex_trust_hint(&self, installed: bool) -> &'static str {
match installed && !self.codex_hooks_heard() {
true => " — run /hooks in it and trust cctop's to see it here",
false => "",
}
}
/// The project a `project`-scoped install would write into: the directory
/// of the selected session, when it has one on this machine.
pub fn hook_project(&self) -> Option<std::path::PathBuf> {
self.selected_session()
.map(|s| std::path::PathBuf::from(&s.label_source))
.filter(|dir| dir.is_dir())
}
/// Install or remove from the panel, and show what happened.
///
/// Every harness at once. The status line gets a count rather than five
/// paths — the panel underneath is redrawn from disk immediately below, and
/// that is where the detail belongs.
pub fn set_hooks(&mut self, scope: crate::hook::Scope, install: bool) {
let done = match install {
true => crate::hook::install(&scope),
false => crate::hook::remove(&scope),
};
self.set_status(format!(
"{} {} agents ({})",
match install {
true => "Asked",
false => "Stopped",
},
done.len(),
scope.label()
));
self.hooks = Some(self.hook_status());
}
/// What the agents have actually said, newest state per session, as
/// `(project, state)` pairs for the panel.
///
/// The project rather than the session id: an id names nothing to a reader,
/// and this list is the answer to "is the thing I just installed working",
/// which needs a name you recognise.
pub fn reporting(&self) -> Vec<(String, &'static str)> {
let mut rows: Vec<(String, &'static str)> = self
.hooked
.values()
.filter(|r| r.is_current())
.map(|r| {
let name = std::path::Path::new(&r.cwd)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "—".into());
(name, r.signal.label())
})
.collect();
rows.sort();
rows
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ui::tests::{session, test_app};
/// Codex's hooks are written to disk and then sit there doing nothing until
/// a person has trusted them, and where that trust is recorded is not
/// something Codex documents — so the only way to know is whether anything
/// has arrived.
///
/// The permission mode is what answers it: `notify`, Codex's other channel,
/// carries no such field, so a Codex row that knows its mode is a Codex
/// whose hooks are firing. Said when a Codex is *started*, which is the
/// moment `/hooks` is a keystroke away rather than a thing to remember.
#[test]
fn a_codex_whose_hooks_are_untrusted_is_told_so_when_it_starts() {
let mut app = test_app();
let mut codex = session("c", true, "proj");
codex.provider = Provider::Codex;
app.sessions = vec![codex];
assert!(!app.codex_hooks_heard(), "nothing has reported yet");
assert!(
!app.codex_trust_hint(true).is_empty(),
"a written-but-silent install is exactly the case worth saying"
);
assert!(
app.codex_trust_hint(false).is_empty(),
"an install nobody asked for is not something to nag about"
);
// One hook event carries the mode, which nothing but a hook does.
app.sessions[0].permission = Some(crate::hook::Permission::Ask);
assert!(app.codex_hooks_heard());
assert!(
app.codex_trust_hint(true).is_empty(),
"the reminder outlived its purpose"
);
// A row from another machine says nothing about this one's hooks: its
// mode came over `--host` from a cctop where they work.
app.sessions[0].remote = Some(crate::session::Remote {
host: "elsewhere".into(),
branch: None,
});
assert!(!app.codex_hooks_heard());
// And a Claude Code row reporting its mode is not Codex's answer.
app.sessions[0].remote = None;
app.sessions[0].provider = Provider::Claude;
assert!(!app.codex_hooks_heard());
}
}