Skip to content

Commit c2327a5

Browse files
author
BrainyBlaze Ops
committed
state: track busy/idle from agent CLI's OSC 0 title
Both codex and claude write the current activity into the terminal window title via OSC 0. While the agent is working, the title is prefixed with a UTF-8 braille spinner glyph (U+2800..U+28FF + space); when it goes idle the prefix drops (codex emits bare project name, claude emits "✳ <subject>"). The convention is reliable across agents and repeats on every turn — unlike claude's one-shot CSI "\x1b[>0q" init marker we previously used. tstate_scan now also parses OSC 0 (terminated by BEL or ST) and classifies each title as busy iff its first UTF-8 code point is in the braille range. master.c installs a state-change callback that persists the result to `<sockname>.state` (single line, "busy" or "idle"). New CLI `atch state <session>` reads that file and prints `busy` / `idle` / `unknown` (unknown for sessions with no title yet or that are gone). Cleanup: `cleanup_session()` unlinks the sidecar on master exit; `rm_main` removes it alongside socket+log in both single and -a modes. Bumped to 1.3-bb1. Existing tests still pass (229/229) and the new state file appears + tracks transitions in a smoke probe against codex 0.132.0.
1 parent 455e56d commit c2327a5

6 files changed

Lines changed: 261 additions & 10 deletions

File tree

‎atch.c‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -682,6 +682,54 @@ static int cmd_tail(int argc, char **argv)
682682
return 0;
683683
}
684684

685+
/* atch state <session> — report agent busy/idle, inferred by master
686+
** from the running CLI's OSC 0 window title (braille spinner = busy,
687+
** anything else = idle). The master keeps `<sockname>.state` in sync
688+
** on every transition; we just read it. Prints one of:
689+
** busy — agent is working
690+
** idle — agent is waiting for input
691+
** unknown — session is alive but no title has been seen yet,
692+
** or the session is dead.
693+
** Always exits 0 — callers script against the printed token. */
694+
static int cmd_state(int argc, char **argv)
695+
{
696+
char state_path[600];
697+
char buf[64];
698+
int fd;
699+
ssize_t n;
700+
701+
if (consume_session(&argc, &argv))
702+
return 1;
703+
if (argc > 0) {
704+
printf("%s: Invalid number of arguments.\n", progname);
705+
printf("Try '%s --help' for more information.\n", progname);
706+
return 1;
707+
}
708+
709+
snprintf(state_path, sizeof(state_path), "%s.state", sockname);
710+
fd = open(state_path, O_RDONLY);
711+
if (fd < 0) {
712+
printf("unknown\n");
713+
return 0;
714+
}
715+
n = read(fd, buf, sizeof(buf) - 1);
716+
close(fd);
717+
if (n <= 0) {
718+
printf("unknown\n");
719+
return 0;
720+
}
721+
buf[n] = '\0';
722+
/* Strip trailing newline (we add our own via printf). */
723+
while (n > 0 && (buf[n - 1] == '\n' || buf[n - 1] == '\r'))
724+
buf[--n] = '\0';
725+
if (n == 0) {
726+
printf("unknown\n");
727+
return 0;
728+
}
729+
printf("%s\n", buf);
730+
return 0;
731+
}
732+
685733
static int cmd_rm(int argc, char **argv)
686734
{
687735
int all = 0;
@@ -773,6 +821,8 @@ static void usage(void)
773821
" rm [-a] [<session>]"
774822
"\t\tRemove stale/exited session(s)\n"
775823
" -a\t\t\t\tRemove all stale and exited sessions\n"
824+
" state <session>"
825+
"\t\t\tPrint agent state: busy | idle | unknown\n"
776826
" current\t\t\t\tPrint current session name\n"
777827
"\n"
778828
"Options:\n"
@@ -964,6 +1014,8 @@ int main(int argc, char **argv)
9641014
return cmd_tail(argc, argv);
9651015
if (is_cmd(cmd, "rm", NULL, NULL))
9661016
return cmd_rm(argc, argv);
1017+
if (is_cmd(cmd, "state", NULL, NULL))
1018+
return cmd_state(argc, argv);
9671019

9681020
/* Smart default: treat first arg as session name → attach-or-create */
9691021
return cmd_open((char *)cmd, argc, argv);

‎atch.h‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,23 @@ void tstate_reset(void);
159159
void tstate_scan(const unsigned char *buf, size_t len);
160160
size_t tstate_emit_preamble(unsigned char *out, size_t outlen);
161161

162+
/* Title-based busy/idle inference.
163+
**
164+
** Both codex and claude write the current state into the window title
165+
** via OSC 0 (\x1b]0;<text>\x07). Convention: a leading UTF-8 braille
166+
** code point (U+2800..U+28FF) followed by a space indicates the agent
167+
** is working ("busy"); any other prefix (including "✳ " for claude
168+
** and bare project text for codex) is idle. `tstate_scan` records
169+
** title changes; `tstate_is_busy` returns -1 (no title seen yet), 0
170+
** (idle), or 1 (busy). `tstate_set_state_callback` installs a hook
171+
** invoked on every busy<->idle transition; master.c uses it to keep
172+
** a sidecar `<sockname>.state` file in sync so `atch state <session>`
173+
** is a constant-time stat+read.
174+
*/
175+
int tstate_is_busy(void);
176+
const char *tstate_get_title(void);
177+
void tstate_set_state_callback(void (*cb)(int busy));
178+
162179
#ifdef sun
163180
#define BROKEN_MASTER
164181
#endif

‎attach.c‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -605,12 +605,14 @@ int kill_main(int force)
605605
int rm_main(int all)
606606
{
607607
char log_path[600];
608+
char state_path[600];
608609

609610
if (!all) {
610611
const char *name = session_shortname();
611612
int s;
612613

613614
snprintf(log_path, sizeof(log_path), "%s.log", sockname);
615+
snprintf(state_path, sizeof(state_path), "%s.state", sockname);
614616

615617
s = connect_socket(sockname);
616618
if (s >= 0) {
@@ -624,6 +626,7 @@ int rm_main(int all)
624626
if (errno == ECONNREFUSED) {
625627
unlink(sockname);
626628
unlink(log_path);
629+
unlink(state_path);
627630
if (!quiet)
628631
printf("%s: session '%s' removed\n",
629632
progname, name);
@@ -635,6 +638,7 @@ int rm_main(int all)
635638

636639
if (stat(log_path, &st) == 0) {
637640
unlink(log_path);
641+
unlink(state_path);
638642
if (!quiet)
639643
printf("%s: session '%s' removed\n",
640644
progname, name);
@@ -654,6 +658,7 @@ int rm_main(int all)
654658
char dir[512];
655659
char path[768];
656660
char log_full[800];
661+
char state_full[800];
657662
DIR *d;
658663
struct dirent *ent;
659664
int count = 0;
@@ -683,8 +688,11 @@ int rm_main(int all)
683688
continue;
684689
snprintf(log_full, sizeof(log_full),
685690
"%s/%s.log", dir, ent->d_name);
691+
snprintf(state_full, sizeof(state_full),
692+
"%s/%s.state", dir, ent->d_name);
686693
unlink(path);
687694
unlink(log_full);
695+
unlink(state_full);
688696
if (!quiet)
689697
printf("%s: removed %s\n",
690698
progname, ent->d_name);
@@ -709,7 +717,11 @@ int rm_main(int all)
709717
continue;
710718
snprintf(log_full, sizeof(log_full),
711719
"%s/%s", dir, ent->d_name);
720+
snprintf(state_full, sizeof(state_full),
721+
"%s/%.*s.state", dir,
722+
(int)(nlen - 4), ent->d_name);
712723
unlink(log_full);
724+
unlink(state_full);
713725
if (!quiet)
714726
printf("%s: removed %.*s\n",
715727
progname, (int)(nlen - 4),

‎config.h‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
#define PACKAGE_NAME "atch"
1414
#ifndef PACKAGE_VERSION
15-
#define PACKAGE_VERSION "1.2-bb1"
15+
#define PACKAGE_VERSION "1.3-bb1"
1616
#endif
1717
#define PACKAGE_URL "https://github.com/BrainyBlaze/atch"
1818
#define RETSIGTYPE void

‎master.c‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,9 +92,12 @@ static int open_log(const char *path)
9292
return fd;
9393
}
9494

95-
/* Write end marker to log, close it, and unlink the socket. */
95+
/* Write end marker to log, close it, drop the state sidecar, and unlink
96+
** the socket. */
9697
static void cleanup_session(void)
9798
{
99+
char state_path[600];
100+
98101
if (log_fd >= 0) {
99102
time_t age = time(NULL) - master_start_time;
100103
char agebuf[32];
@@ -108,9 +111,36 @@ static void cleanup_session(void)
108111
close(log_fd);
109112
log_fd = -1;
110113
}
114+
snprintf(state_path, sizeof(state_path), "%s.state", sockname);
115+
unlink(state_path);
111116
unlink(sockname);
112117
}
113118

119+
/* Persist a busy/idle transition to <sockname>.state. Single-line file,
120+
** consumed by `atch state <session>` and any external pollers. Wired
121+
** into tstate via tstate_set_state_callback() before the master loop
122+
** starts. Best-effort: any I/O failure is silently ignored so a flaky
123+
** filesystem can't crash the multiplexer. */
124+
static void persist_state(int busy)
125+
{
126+
char path[600];
127+
int fd;
128+
const char *line;
129+
130+
if (snprintf(path, sizeof(path), "%s.state", sockname)
131+
>= (int)sizeof(path))
132+
return;
133+
fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0600);
134+
if (fd < 0)
135+
return;
136+
line = busy ? "busy\n" : "idle\n";
137+
if (write(fd, line, strlen(line)) < 0) {
138+
/* ignore — file may still be partially written, which the
139+
** reader treats as "unknown" via short read handling. */
140+
}
141+
close(fd);
142+
}
143+
114144
/* Signal */
115145
static RETSIGTYPE master_die(int sig)
116146
{
@@ -830,6 +860,7 @@ int master_main(char **argv, int waitattach, int dontfork)
830860
}
831861
master_start_time = time(NULL);
832862
tstate_reset();
863+
tstate_set_state_callback(persist_state);
833864
#if defined(F_SETFD) && defined(FD_CLOEXEC)
834865
fcntl(s, F_SETFD, FD_CLOEXEC);
835866

0 commit comments

Comments
 (0)