387 lines
9.6 KiB
Perl
Executable File
387 lines
9.6 KiB
Perl
Executable File
#!/usr/bin/env perl
|
|
#
|
|
# AI handout:
|
|
# - Run this controller, then enter one watched tmux target per line.
|
|
# - Get the correct target from the tmux pane with tmux command mode:
|
|
# :display-message -p '#{socket_path} #{pane_id}'
|
|
# - Target input format is:
|
|
# /tmp/tmux-.../socket %pane
|
|
# - The watcher confirms Codex prompts that contain:
|
|
# Would you like to run the following command?
|
|
# or:
|
|
# Would you like to make the following edits?
|
|
# plus:
|
|
# 1. Yes, proceed (y)
|
|
# - The watcher sends only Enter. It does not send "y".
|
|
# - Ctrl-C in controller mode closes watcher pipes started by that controller.
|
|
# - Logging goes to stderr by default. Set WATCHER_LOG=/path to also append a
|
|
# log file.
|
|
use strict;
|
|
use warnings;
|
|
use Fcntl qw(O_RDWR O_NONBLOCK);
|
|
use IO::Select;
|
|
use POSIX qw(strftime mkfifo);
|
|
|
|
my $SCRIPT = '/Users/jetpac/bin/watcher.pl';
|
|
my $COOLDOWN = $ENV{WATCHER_COOLDOWN} || 60;
|
|
my $LOG_FILE = $ENV{WATCHER_LOG} || '';
|
|
my @PROMPTS = (
|
|
'Would you like to run the following command?',
|
|
'Would you like to make the following edits?',
|
|
);
|
|
my $YES = '1. Yes, proceed (y)';
|
|
|
|
if (@ARGV && $ARGV[0] eq '--watcher') {
|
|
shift @ARGV;
|
|
watcher(@ARGV);
|
|
exit 0;
|
|
}
|
|
|
|
if (@ARGV && ($ARGV[0] eq '-h' || $ARGV[0] eq '--help')) {
|
|
print_help();
|
|
exit 0;
|
|
}
|
|
|
|
controller();
|
|
exit 0;
|
|
|
|
sub controller {
|
|
my %active;
|
|
my $stopping = 0;
|
|
my $event_path = "/tmp/watcher-events-$$";
|
|
my $select = IO::Select->new();
|
|
$select->add(\*STDIN);
|
|
|
|
mkfifo($event_path, 0600) or die "mkfifo $event_path failed: $!\n";
|
|
sysopen(my $event_fh, $event_path, O_RDWR | O_NONBLOCK)
|
|
or die "open $event_path failed: $!\n";
|
|
$select->add($event_fh);
|
|
|
|
local $SIG{INT} = sub { $stopping = 'INT' };
|
|
local $SIG{TERM} = sub { $stopping = 'TERM' };
|
|
|
|
print_help();
|
|
log_msg("ready; enter targets as: /tmp/tmux-.../socket %pane");
|
|
|
|
while (1) {
|
|
if ($stopping) {
|
|
stop_active_pipes(\%active, "caught SIG$stopping");
|
|
unlink $event_path;
|
|
return;
|
|
}
|
|
|
|
for my $fh ($select->can_read(1)) {
|
|
if ($stopping) {
|
|
stop_active_pipes(\%active, "caught SIG$stopping");
|
|
unlink $event_path;
|
|
return;
|
|
}
|
|
|
|
if ($fh == $event_fh) {
|
|
my $event = <$event_fh>;
|
|
next if !defined $event;
|
|
chomp $event;
|
|
handle_event(\%active, $event);
|
|
next;
|
|
}
|
|
|
|
my $line = <$fh>;
|
|
if (!defined $line) {
|
|
log_msg("stdin closed; leaving existing tmux pipes running");
|
|
unlink $event_path;
|
|
return;
|
|
}
|
|
|
|
chomp $line;
|
|
$line =~ s/^\s+//;
|
|
$line =~ s/\s+$//;
|
|
next if $line eq '' || $line =~ /^#/;
|
|
|
|
my ($sock, $pane) = parse_target($line);
|
|
if (!$sock) {
|
|
log_msg("ignored invalid target: $line");
|
|
next;
|
|
}
|
|
|
|
my $key = "$sock $pane";
|
|
if ($active{$key}) {
|
|
log_msg("already watching $key");
|
|
next;
|
|
}
|
|
|
|
if (!target_exists($sock, $pane)) {
|
|
log_msg("target does not exist: $key");
|
|
next;
|
|
}
|
|
|
|
if (start_pipe($sock, $pane, $event_path)) {
|
|
$active{$key} = time;
|
|
log_msg("watching $key");
|
|
} else {
|
|
log_msg("failed to start pipe for $key");
|
|
}
|
|
}
|
|
|
|
for my $key (keys %active) {
|
|
my ($sock, $pane) = split / /, $key, 2;
|
|
if (!target_exists($sock, $pane)) {
|
|
delete $active{$key};
|
|
log_msg("stopped tracking missing target $key");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
sub handle_event {
|
|
my ($active, $event) = @_;
|
|
|
|
if ($event =~ /^EXIT\t([^\t]+)\t(%\d+)\t(.*)$/) {
|
|
my ($sock, $pane, $reason) = ($1, $2, $3);
|
|
my $key = "$sock $pane";
|
|
if (delete $active->{$key}) {
|
|
log_msg("watched pane ended: $key ($reason)");
|
|
} else {
|
|
log_msg("watcher exited for untracked target: $key ($reason)");
|
|
}
|
|
return;
|
|
}
|
|
|
|
log_msg("ignored watcher event: $event");
|
|
}
|
|
|
|
sub print_help {
|
|
print STDERR <<"EOF";
|
|
watcher.pl
|
|
|
|
Enter one target per line:
|
|
/tmp/tmux-.../socket %pane
|
|
|
|
To get the correct target from the tmux pane you want watched, run this in tmux command mode:
|
|
:display-message -p '#{socket_path} #{pane_id}'
|
|
|
|
Stop with Ctrl-C. The controller will close watcher pipes it started.
|
|
|
|
EOF
|
|
}
|
|
|
|
sub stop_active_pipes {
|
|
my ($active, $why) = @_;
|
|
log_msg("$why; stopping " . scalar(keys %$active) . " active watcher pipe(s)");
|
|
|
|
for my $key (sort keys %$active) {
|
|
my ($sock, $pane) = split / /, $key, 2;
|
|
if (stop_pipe($sock, $pane)) {
|
|
log_msg("stopped watcher pipe for $key");
|
|
} else {
|
|
log_msg("failed to stop watcher pipe for $key");
|
|
}
|
|
delete $active->{$key};
|
|
}
|
|
}
|
|
|
|
sub watcher {
|
|
my ($sock, $pane, $event_path) = @_;
|
|
die "usage: $SCRIPT --watcher SOCKET %pane\n"
|
|
unless defined $sock && defined $pane;
|
|
|
|
binmode STDIN;
|
|
|
|
my $last_confirm = 0;
|
|
my $last_signature = '';
|
|
my $recent = '';
|
|
my $exit_reason = 'pipe closed';
|
|
|
|
maybe_confirm($sock, $pane, \$last_confirm, \$last_signature, 'initial');
|
|
|
|
while (1) {
|
|
my $bytes = read(STDIN, my $chunk, 4096);
|
|
if (!defined($bytes)) {
|
|
$exit_reason = "read error: $!";
|
|
last;
|
|
}
|
|
last if $bytes == 0;
|
|
|
|
$recent .= clean_text($chunk);
|
|
$recent = substr($recent, -8192) if length($recent) > 8192;
|
|
|
|
next if !has_any_prompt($recent);
|
|
next if index($recent, $YES) < 0;
|
|
|
|
maybe_confirm($sock, $pane, \$last_confirm, \$last_signature, 'stream');
|
|
}
|
|
|
|
notify_controller($event_path, "EXIT\t$sock\t$pane\t$exit_reason")
|
|
if defined $event_path && $event_path ne '';
|
|
}
|
|
|
|
sub maybe_confirm {
|
|
my ($sock, $pane, $last_confirm_ref, $last_signature_ref, $source) = @_;
|
|
my $now = time;
|
|
|
|
my ($ok, $screen) = rendered_visible_pane($sock, $pane);
|
|
exit 0 if !$ok;
|
|
|
|
return if !has_any_prompt($screen);
|
|
return if index($screen, $YES) < 0;
|
|
|
|
my $signature = prompt_signature($screen);
|
|
if ($signature eq $$last_signature_ref && $now - $$last_confirm_ref < $COOLDOWN) {
|
|
return;
|
|
}
|
|
|
|
if (run_quiet('tmux', '-S', $sock, 'send-keys', '-t', $pane, 'Enter')) {
|
|
$$last_confirm_ref = $now;
|
|
$$last_signature_ref = $signature;
|
|
log_msg("sent Enter to confirm $sock $pane from $source");
|
|
} else {
|
|
log_msg("failed to confirm $sock $pane; stopping watcher");
|
|
exit 0;
|
|
}
|
|
}
|
|
|
|
sub rendered_visible_pane {
|
|
my ($sock, $pane) = @_;
|
|
my @cmd = (
|
|
'tmux', '-S', $sock,
|
|
'capture-pane', '-p',
|
|
'-t', $pane,
|
|
);
|
|
|
|
my ($ok, $out) = run_capture(@cmd);
|
|
return (0, '') if !$ok;
|
|
|
|
return (1, clean_text($out));
|
|
}
|
|
|
|
sub start_pipe {
|
|
my ($sock, $pane, $event_path) = @_;
|
|
my $cmd = join ' ',
|
|
map { tmux_pipe_shell_quote($_) }
|
|
($^X, $SCRIPT, '--watcher', $sock, $pane, $event_path);
|
|
|
|
return run_quiet(
|
|
'tmux', '-S', $sock,
|
|
'pipe-pane', '-t', $pane,
|
|
'-o', $cmd,
|
|
);
|
|
}
|
|
|
|
sub notify_controller {
|
|
my ($event_path, $event) = @_;
|
|
return if !defined $event_path || $event_path eq '';
|
|
|
|
if (open my $fh, '>', $event_path) {
|
|
print $fh "$event\n";
|
|
close $fh;
|
|
}
|
|
}
|
|
|
|
sub stop_pipe {
|
|
my ($sock, $pane) = @_;
|
|
return run_quiet(
|
|
'tmux', '-S', $sock,
|
|
'pipe-pane', '-t', $pane,
|
|
);
|
|
}
|
|
|
|
sub target_exists {
|
|
my ($sock, $pane) = @_;
|
|
return run_quiet(
|
|
'tmux', '-S', $sock,
|
|
'display-message', '-p',
|
|
'-t', $pane,
|
|
'#{pane_id}',
|
|
);
|
|
}
|
|
|
|
sub parse_target {
|
|
my ($line) = @_;
|
|
return ($1, $2) if $line =~ /^(\S+)\s+(%\d+)$/;
|
|
return;
|
|
}
|
|
|
|
sub clean_text {
|
|
my ($text) = @_;
|
|
$text =~ s/\e\][^\a]*(?:\a|\e\\)//g; # OSC
|
|
$text =~ s/\e\[[0-?]*[ -\/]*[@-~]//g; # CSI
|
|
$text =~ s/\e[ -\/]*[@-~]//g; # Other ESC sequences
|
|
$text =~ tr/\r/\n/;
|
|
$text =~ s/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]//g;
|
|
return $text;
|
|
}
|
|
|
|
sub has_any_prompt {
|
|
my ($text) = @_;
|
|
for my $prompt (@PROMPTS) {
|
|
return 1 if index($text, $prompt) >= 0;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
sub prompt_signature {
|
|
my ($text) = @_;
|
|
$text = clean_text($text);
|
|
$text =~ s/\s+/ /g;
|
|
$text =~ s/^\s+//;
|
|
$text =~ s/\s+$//;
|
|
return $text;
|
|
}
|
|
|
|
sub run_capture {
|
|
my (@cmd) = @_;
|
|
my $pid = open(my $fh, '-|');
|
|
die "fork failed: $!\n" unless defined $pid;
|
|
|
|
if ($pid == 0) {
|
|
open STDERR, '>', '/dev/null' or exit 127;
|
|
exec @cmd;
|
|
exit 127;
|
|
}
|
|
|
|
local $/;
|
|
my $out = <$fh>;
|
|
my $ok = close $fh;
|
|
return ($ok ? 1 : 0, defined($out) ? $out : '');
|
|
}
|
|
|
|
sub run_quiet {
|
|
my (@cmd) = @_;
|
|
my $pid = fork();
|
|
return 0 unless defined $pid;
|
|
|
|
if ($pid == 0) {
|
|
open STDOUT, '>', '/dev/null' or exit 127;
|
|
open STDERR, '>', '/dev/null' or exit 127;
|
|
exec @cmd;
|
|
exit 127;
|
|
}
|
|
|
|
waitpid($pid, 0);
|
|
return $? == 0;
|
|
}
|
|
|
|
sub shell_quote {
|
|
my ($s) = @_;
|
|
$s =~ s/'/'"'"'/g;
|
|
return "'$s'";
|
|
}
|
|
|
|
sub tmux_pipe_shell_quote {
|
|
my ($s) = @_;
|
|
# pipe-pane expands percent escapes in the shell command before execution.
|
|
$s =~ s/%/%%/g;
|
|
return shell_quote($s);
|
|
}
|
|
|
|
sub log_msg {
|
|
my ($msg) = @_;
|
|
my $ts = strftime('%Y-%m-%dT%H:%M:%SZ', gmtime());
|
|
my $line = "$ts watcher.pl: $msg\n";
|
|
print STDERR $line;
|
|
|
|
if ($LOG_FILE ne '' && open my $fh, '>>', $LOG_FILE) {
|
|
print $fh $line;
|
|
close $fh;
|
|
}
|
|
}
|