agent-tty 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
agent_tty/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """agent-tty: persistent terminal sessions for AI agents."""
2
+ __version__ = "0.1.0"
agent_tty/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Allow `python -m agent_tty`."""
2
+ from agent_tty.cli import main
3
+ raise SystemExit(main())
agent_tty/cli.py ADDED
@@ -0,0 +1,840 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ agent-tty / k -- persistent terminal sessions for AI agents
4
+
5
+ Usage:
6
+ k new <session> [cmd...] [--prompt="x"] spawn session (default: bash)
7
+ k new <session> <cmd> --prompt=./hook hook mode (custom frame detect)
8
+ k fire [-t N] [session] <code> async fire (default 300s)
9
+ k poll [session] [cell_id] poll result (O(1))
10
+ k run [-j] [-t N] [session] <code> sync (default 30s)
11
+ k await ... alias for run
12
+ k notify [session] <message> notification
13
+ k int [session] ctrl-c
14
+ k kill <session> kill + cleanup
15
+ k ls list sessions
16
+ k status [session] health check
17
+ k watch [session] live filtered view
18
+ k history [-n N] [session] last N*5 lines (default 5)
19
+
20
+ Session resolves: explicit arg > K_SESSION env > auto-detect.
21
+
22
+ Frame detection (--prompt):
23
+ not set -> 5 empty Enters, detect repeated prompt lines (zero config)
24
+ "string" -> exact prompt match (e.g. --prompt="(gdb)")
25
+ ./file -> stdin hook: k feeds lines, hook exit = frame end
26
+ hook path canonicalised to absolute at k new time; must exist and be executable
27
+
28
+ JSON output (-j / fire / poll):
29
+ fired: {"cell_id": "...", "status": "fired"}
30
+ running: {"cell_id": "...", "status": "running"}
31
+ done: {"cell_id": "...", "status": "done", "output": "..."}
32
+ timeout: {"cell_id": "...", "status": "timeout", "output": ""}
33
+ timeout(2+): {"cell_id": "...", "status": "timeout", "output": "use k int or k kill"}
34
+ error: {"status": "error", "output": "..."}
35
+ cell error: {"cell_id": "...", "status": "error", "output": "..."}
36
+
37
+ Errors without cell_id: no session, active cell, pipe failed, send failed, no active cell
38
+ Errors with cell_id: interrupted, unknown cell, watcher died, lock update failed, interrupt failed
39
+
40
+ Timeout: lock is NOT released (command may still be running).
41
+ Only k int or k kill releases. k int sends ctrl-c, writes interrupted, releases lock.
42
+
43
+ Monitor (separate command):
44
+ km <session> [cell_id] [-1] event stream -- each stdout line is one JSON event
45
+ -1 = exit after first completion (one-shot)
46
+ Events: fired, done, notify, closed, error (all include "ts" field)
47
+ """
48
+ import json, os, re, signal, shutil, subprocess, sys, time, uuid
49
+
50
+ TMUX = shutil.which("tmux") or "tmux"
51
+ CELL_DIR = "/tmp/k_cells"
52
+ FRAME_ENTERS = 5 # consecutive identical lines to detect frame end
53
+
54
+ ANSI_RE = re.compile(
55
+ r"\x1b\[[0-9;]*[a-zA-Z]|\x1b\[<[0-9;]*[mM]|\x1b\[\?[0-9;]*[hlsr]"
56
+ r"|\x1b\][^\x07]*\x07|\x1b\][^\x1b]*\x1b\\|\x1b[()][0-9A-B]"
57
+ r"|\x1b[>=]|\x1b\x50[^\x1b]*\x1b\\|\x08|\r"
58
+ )
59
+
60
+
61
+ # ═══════════════════════════════════════════
62
+ # TMUX
63
+ # ═══════════════════════════════════════════
64
+
65
+ class T:
66
+ @staticmethod
67
+ def spawn(s, cmd):
68
+ subprocess.run([TMUX, "new-session", "-d", "-s", s, "-x", "10000", "-y", "50"]
69
+ + ([cmd] if cmd else []), check=True)
70
+ @staticmethod
71
+ def has(s):
72
+ return subprocess.run([TMUX, "has-session", "-t", s], capture_output=True).returncode == 0
73
+ @staticmethod
74
+ def kill(s):
75
+ subprocess.run([TMUX, "kill-session", "-t", s], capture_output=True)
76
+ @staticmethod
77
+ def send(s, text):
78
+ subprocess.run([TMUX, "send-keys", "-t", s, text, "Enter"], check=True)
79
+ @staticmethod
80
+ def send_enter(s):
81
+ subprocess.run([TMUX, "send-keys", "-t", s, "", "Enter"], check=True)
82
+ @staticmethod
83
+ def send_int(s):
84
+ subprocess.run([TMUX, "send-keys", "-t", s, "C-c"], check=True)
85
+ @staticmethod
86
+ def ls():
87
+ r = subprocess.run([TMUX, "list-sessions", "-F", "#{session_name}"],
88
+ capture_output=True, text=True)
89
+ return r.stdout.strip()
90
+ @staticmethod
91
+ def pipe_start(s, logfile):
92
+ open(logfile, "a").close()
93
+ subprocess.run([TMUX, "pipe-pane", "-t", s, f"cat >> '{logfile}'"], check=True)
94
+ @staticmethod
95
+ def pipe_stop(s):
96
+ subprocess.run([TMUX, "pipe-pane", "-t", s], capture_output=True)
97
+
98
+
99
+ # ═══════════════════════════════════════════
100
+ # PATHS + HELPERS
101
+ # ═══════════════════════════════════════════
102
+
103
+ def _meta(s): return os.path.join(CELL_DIR, s, "_session.json")
104
+ def _lock(s): return os.path.join(CELL_DIR, s, "_lock.json")
105
+ def _log(s): return os.path.join(CELL_DIR, s, "_output.log")
106
+ def _result(s, cid): return os.path.join(CELL_DIR, s, f"{cid}_result.json")
107
+
108
+ def _log_size(s):
109
+ try: return os.path.getsize(_log(s))
110
+ except FileNotFoundError: return 0
111
+
112
+ def _ensure_pipe(s):
113
+ """(Re)start pipe-pane. Idempotent — replaces dead/existing pipe."""
114
+ logpath = _log(s)
115
+ os.makedirs(os.path.join(CELL_DIR, s), exist_ok=True)
116
+ T.pipe_start(s, logpath)
117
+
118
+ def _log_event(s, event):
119
+ try:
120
+ with open(_log(s), "a") as f: f.write(f"\n{event}\n")
121
+ except OSError: pass
122
+
123
+ def _resolve(explicit=None):
124
+ if explicit:
125
+ _validate_name(explicit)
126
+ return explicit
127
+ env = os.environ.get("K_SESSION")
128
+ if env:
129
+ _validate_name(env)
130
+ return env
131
+ if os.path.isdir(CELL_DIR):
132
+ ss = [d for d in os.listdir(CELL_DIR) if os.path.isfile(os.path.join(CELL_DIR, d, "_session.json"))]
133
+ if len(ss) == 1:
134
+ _validate_name(ss[0])
135
+ return ss[0]
136
+ return None
137
+
138
+ def _json(d): print(json.dumps(d, ensure_ascii=False))
139
+
140
+ def _emit(json_out, data, text=None):
141
+ """Unified output: JSON mode → _json(data), text mode → print(text)."""
142
+ if json_out: _json(data)
143
+ else: print(text if text is not None else data.get("output", ""))
144
+
145
+ def _kill_watcher(meta):
146
+ """SIGTERM bg watcher if present. Returns True if killed."""
147
+ if "bg_pid" not in meta: return False
148
+ try: os.kill(meta["bg_pid"], signal.SIGTERM)
149
+ except OSError: return False
150
+ return True
151
+
152
+ def _write_result(session, cell_id, result):
153
+ """Atomic result write: tmp + fsync + os.replace. No partial reads."""
154
+ rpath = _result(session, cell_id)
155
+ tmp = rpath + ".tmp"
156
+ with open(tmp, "w") as f:
157
+ json.dump(result, f)
158
+ f.flush()
159
+ os.fsync(f.fileno())
160
+ os.replace(tmp, rpath)
161
+
162
+ def _update_lock(session, **kw):
163
+ """Read-modify-write lock file. Returns True on success, False on failure."""
164
+ try:
165
+ with open(_lock(session), "r+") as f:
166
+ meta = json.load(f)
167
+ meta.update(kw)
168
+ f.seek(0); f.truncate()
169
+ json.dump(meta, f)
170
+ return True
171
+ except Exception:
172
+ return False
173
+
174
+ _SAFE_NAME = re.compile(r'^[A-Za-z0-9_.-]+$')
175
+ def _validate_name(s):
176
+ """Reject path traversal / injection in session names."""
177
+ if not s or not _SAFE_NAME.match(s) or '..' in s:
178
+ print(f"ERR invalid session name: {s!r}"); sys.exit(1)
179
+
180
+
181
+ # ═══════════════════════════════════════════
182
+ # SESSION
183
+ # ═══════════════════════════════════════════
184
+
185
+ def _create(session, cmd, prompt=None):
186
+ T.spawn(session, cmd)
187
+ os.makedirs(os.path.join(CELL_DIR, session), exist_ok=True)
188
+ _ensure_pipe(session)
189
+ time.sleep(1.0)
190
+ meta = {"name": session}
191
+ if prompt:
192
+ meta["prompt"] = prompt # explicit prompt → exact match mode
193
+ with open(_meta(session), "w") as f:
194
+ json.dump(meta, f)
195
+
196
+ def _session_exists(session):
197
+ return T.has(session) and os.path.exists(_meta(session))
198
+
199
+ def _session_prompt(session):
200
+ """Returns explicit prompt if set, None for default repeat-detection."""
201
+ try:
202
+ with open(_meta(session)) as f: return json.load(f).get("prompt")
203
+ except Exception: return None
204
+
205
+
206
+ # ═══════════════════════════════════════════
207
+ # LOCK = CELL METADATA
208
+ # ═══════════════════════════════════════════
209
+
210
+ def _acquire(session, cell_id, log_offset, echo_count):
211
+ lock = _lock(session)
212
+ meta = {"cell_id": cell_id, "log_offset": log_offset, "echo_count": echo_count}
213
+ try:
214
+ fd = os.open(lock, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644)
215
+ os.write(fd, json.dumps(meta).encode())
216
+ os.close(fd)
217
+ return None
218
+ except FileExistsError:
219
+ try:
220
+ with open(lock) as f: return json.load(f).get("cell_id", "?")
221
+ except Exception: return "?"
222
+
223
+ def _load_cell(session):
224
+ try:
225
+ with open(_lock(session)) as f: return json.load(f)
226
+ except Exception: return None
227
+
228
+ def _release(session, cell_id):
229
+ try:
230
+ with open(_lock(session)) as f:
231
+ if json.load(f).get("cell_id") == cell_id: os.unlink(_lock(session))
232
+ except Exception: pass
233
+
234
+
235
+ def _send_interrupt(session):
236
+ """Send Ctrl-C to REPL + re-send frame enters in repeat mode.
237
+ Returns True if Ctrl-C was delivered (or session is already dead).
238
+ Returns False if Ctrl-C failed but session is still alive — caller must not release.
239
+ """
240
+ prompt = _session_prompt(session)
241
+ try:
242
+ T.send_int(session)
243
+ except Exception:
244
+ # Ctrl-C didn't reach REPL. If session is dead, nothing is running → safe.
245
+ # If session is alive, command may still be running → unsafe to release.
246
+ return not T.has(session)
247
+ time.sleep(0.3)
248
+ # re-frame is best-effort (Ctrl-C already delivered)
249
+ if not prompt:
250
+ try:
251
+ _send_frame_enters(session)
252
+ except Exception:
253
+ pass
254
+ return True
255
+
256
+
257
+ class CellBusy(Exception):
258
+ """Raised by CellLock when the session already has an active cell."""
259
+ def __init__(self, held_id):
260
+ self.held_id = held_id
261
+
262
+
263
+ class CellLock:
264
+ """RAII lock for cell lifecycle. Three states via sent/keep:
265
+ pre-send (default) → release on any exit
266
+ post-send (sent) → interrupt recovery on exception, release on normal exit
267
+ keep (timeout/fire) → lock stays held, no cleanup
268
+ """
269
+ def __init__(self, session, cell_id, log_offset, echo_count):
270
+ self.session = session
271
+ self.cell_id = cell_id
272
+ self.sent = False
273
+ self.keep = False
274
+ self.interrupt_failed = False
275
+ held = _acquire(session, cell_id, log_offset, echo_count)
276
+ if held:
277
+ raise CellBusy(held)
278
+
279
+ def __enter__(self):
280
+ return self
281
+
282
+ def mark_sent(self):
283
+ self.sent = True
284
+
285
+ def mark_keep(self):
286
+ self.keep = True
287
+
288
+ def __exit__(self, exc_type, exc_val, exc_tb):
289
+ if self.keep:
290
+ return False
291
+ if exc_type is not None and self.sent:
292
+ if not _send_interrupt(self.session):
293
+ # Ctrl-C didn't reach REPL but session is alive — keep lock
294
+ # (same reasoning as timeout: command may still be running)
295
+ self.interrupt_failed = True
296
+ return False
297
+ _release(self.session, self.cell_id)
298
+ # sync mode cleanup: remove result file (nobody will poll it)
299
+ try:
300
+ rpath = _result(self.session, self.cell_id)
301
+ if os.path.exists(rpath): os.unlink(rpath)
302
+ except OSError: pass
303
+ return False
304
+
305
+
306
+ # ═══════════════════════════════════════════
307
+ # STREAM PROCESSOR
308
+ # Frame delimiter: N consecutive identical non-empty lines
309
+ # (= REPL redrawing prompt on empty Enter)
310
+ # ═══════════════════════════════════════════
311
+
312
+ def _stream_process(session, cell_id, log_offset, echo_count, timeout=None, prompt=None):
313
+ """
314
+ Stream processor with three modes:
315
+ prompt=None → frame = N consecutive identical lines (default)
316
+ prompt="string" → frame = exact prompt match
317
+ prompt="./file" → frame = hook process (stdin lines, exit=done)
318
+ """
319
+ logpath = _log(session)
320
+ state = "OUTPUT" if echo_count <= 0 else "ECHOING"
321
+ remaining = echo_count
322
+ output = []
323
+ deadline = time.monotonic() + timeout if timeout else None
324
+ repeat_count = 0
325
+ last_clean = None
326
+
327
+ # start hook process if prompt is an absolute file path (canonicalised by cmd_new)
328
+ hook = None
329
+ if prompt and os.path.isabs(prompt) and os.path.isfile(prompt):
330
+ hook = subprocess.Popen(
331
+ [prompt], stdin=subprocess.PIPE, stdout=subprocess.DEVNULL,
332
+ stderr=subprocess.DEVNULL, text=True
333
+ )
334
+ prompt = None # don't also do string matching
335
+
336
+ last_appended = False # tracks if last line was appended (not filtered)
337
+ timed_out = False
338
+
339
+ try:
340
+ with open(logpath, "r", errors="replace") as f:
341
+ f.seek(log_offset)
342
+ while True:
343
+ if deadline and time.monotonic() > deadline:
344
+ timed_out = True
345
+ break
346
+
347
+ line = f.readline()
348
+ if not line:
349
+ if hook and hook.poll() is not None:
350
+ # hook exited — pop last line only if it was appended
351
+ if output and last_appended:
352
+ output.pop()
353
+ break
354
+ time.sleep(0.05)
355
+ continue
356
+
357
+ clean = ANSI_RE.sub("", line).strip()
358
+ if not clean:
359
+ continue
360
+
361
+ if clean.startswith("── cell:") or clean.startswith("── notify "):
362
+ continue
363
+
364
+ if state == "ECHOING":
365
+ remaining -= 1
366
+ if remaining <= 0:
367
+ state = "OUTPUT"
368
+
369
+ elif state == "OUTPUT":
370
+ if hook:
371
+ # hook mode: feed line, exit = frame end
372
+ # NO filtering — hook user takes full control of output
373
+ try:
374
+ hook.stdin.write(clean + "\n")
375
+ hook.stdin.flush()
376
+ except (BrokenPipeError, OSError):
377
+ if output and last_appended:
378
+ output.pop()
379
+ break
380
+ output.append(clean)
381
+ last_appended = True
382
+ time.sleep(0.01)
383
+ if hook.poll() is not None:
384
+ output.pop()
385
+ break
386
+ elif prompt:
387
+ # string mode: exact match
388
+ if clean == prompt:
389
+ break
390
+ if clean == "..." or clean.startswith("... "):
391
+ continue
392
+ output.append(clean)
393
+ else:
394
+ # repeat mode: N consecutive identical lines
395
+ if clean == "..." or clean.startswith("... "):
396
+ last_clean = clean
397
+ continue
398
+
399
+ if clean == last_clean:
400
+ repeat_count += 1
401
+ else:
402
+ repeat_count = 0
403
+
404
+ output.append(clean)
405
+
406
+ if repeat_count >= FRAME_ENTERS - 1:
407
+ for _ in range(repeat_count + 1):
408
+ output.pop()
409
+ break
410
+ last_clean = clean
411
+ finally:
412
+ if hook:
413
+ if hook.poll() is None:
414
+ hook.kill()
415
+ hook.wait()
416
+
417
+ result = {
418
+ "cell_id": cell_id,
419
+ "status": "timeout" if timed_out else "done",
420
+ "output": "" if timed_out else "\n".join(output)
421
+ }
422
+
423
+ _write_result(session, cell_id, result)
424
+ if not timed_out:
425
+ _log_event(session, f"── cell:{cell_id} done ──")
426
+
427
+ return result
428
+
429
+
430
+ def _echo_count(code):
431
+ """Count how many lines the REPL will echo (= non-trailing-blank lines)."""
432
+ code_lines = code.lstrip().split("\n")
433
+ count = len(code_lines)
434
+ while count > 0 and not code_lines[count - 1].strip():
435
+ count -= 1
436
+ return count
437
+
438
+
439
+ def _send_frame_enters(session):
440
+ """Send FRAME_ENTERS empty Enters via send-keys (repeat-mode framing)."""
441
+ args = [TMUX, "send-keys", "-t", session]
442
+ for _ in range(FRAME_ENTERS):
443
+ args.extend(["", "Enter"])
444
+ subprocess.run(args, check=True)
445
+
446
+
447
+ def _send_code(session, code, prompt=None):
448
+ """Send code via paste-buffer (no per-char echo) + frame enters."""
449
+ code_lines = code.lstrip().split("\n")
450
+
451
+ # paste-buffer: entire text arrives as one write → readline redraws once
452
+ text = "\n".join(code_lines) + "\n"
453
+ buf = f"k_{session}"
454
+ subprocess.run([TMUX, "load-buffer", "-b", buf, "-"], input=text.encode(), check=True)
455
+ subprocess.run([TMUX, "paste-buffer", "-b", buf, "-d", "-t", session], check=True)
456
+
457
+ if not prompt:
458
+ _send_frame_enters(session)
459
+
460
+
461
+ # ═══════════════════════════════════════════
462
+ # COMMANDS
463
+ # ═══════════════════════════════════════════
464
+
465
+ def cmd_new(session, cmd_parts, prompt=None):
466
+ _validate_name(session)
467
+ if T.has(session):
468
+ print(f"OK {session} (alive)")
469
+ return 0
470
+ # hook mode: path contains / or \ → canonicalize and fail early if missing
471
+ if prompt and (os.sep in prompt or "/" in prompt):
472
+ prompt = os.path.abspath(os.path.expanduser(prompt))
473
+ if not os.path.isfile(prompt):
474
+ print(f"ERR hook not found: {prompt}"); return 1
475
+ if not os.access(prompt, os.R_OK):
476
+ print(f"ERR hook not readable: {prompt}"); return 1
477
+ if not os.access(prompt, os.X_OK):
478
+ print(f"ERR hook not executable: {prompt}"); return 1
479
+ cmd = " ".join(cmd_parts) if cmd_parts else "bash"
480
+ _create(session, cmd, prompt)
481
+ if prompt:
482
+ print(f"OK {session} prompt={repr(prompt)}")
483
+ else:
484
+ print(f"OK {session}")
485
+ return 0
486
+
487
+
488
+ def cmd_fire(session, code, timeout=300):
489
+ if not _session_exists(session):
490
+ _json({"status": "error", "output": f"no session '{session}'"}); return 1
491
+
492
+ cell_id = uuid.uuid4().hex[:12]
493
+ prompt = _session_prompt(session)
494
+ echo_count = _echo_count(code)
495
+ log_offset = _log_size(session)
496
+
497
+ try:
498
+ lock = CellLock(session, cell_id, log_offset, echo_count)
499
+ except CellBusy as e:
500
+ _json({"status": "error", "output": f"active cell '{e.held_id}'"}); return 1
501
+
502
+ try:
503
+ with lock:
504
+ try:
505
+ _ensure_pipe(session)
506
+ except Exception as e:
507
+ _json({"status": "error", "output": f"pipe failed: {e}"}); return 1
508
+
509
+ try:
510
+ _send_code(session, code, prompt)
511
+ except Exception as e:
512
+ _json({"status": "error", "output": f"send failed: {e}"}); return 1
513
+
514
+ lock.mark_sent()
515
+ _log_event(session, f"── cell:{cell_id} fired ──")
516
+
517
+ bg_args = [sys.executable, os.path.abspath(__file__), "_bg",
518
+ session, cell_id, str(log_offset), str(echo_count), str(timeout)]
519
+ if prompt:
520
+ bg_args.append(prompt)
521
+
522
+ bg = subprocess.Popen(bg_args, start_new_session=True,
523
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
524
+
525
+ # store bg PID in lock — without it orphan detection is blind
526
+ if not _update_lock(session, bg_pid=bg.pid):
527
+ try: bg.kill()
528
+ except OSError: pass
529
+ raise RuntimeError("lock update failed")
530
+
531
+ lock.mark_keep() # bg process owns the lock now
532
+ except (Exception, KeyboardInterrupt):
533
+ msg = "interrupt failed; use k kill" if lock.interrupt_failed else "interrupted"
534
+ _json({"cell_id": cell_id, "status": "error", "output": msg})
535
+ return 1
536
+
537
+ _json({"cell_id": cell_id, "status": "fired"})
538
+ return 0
539
+
540
+
541
+ def cmd_poll(session, cell_id=None):
542
+ if cell_id is None:
543
+ meta = _load_cell(session)
544
+ if not meta:
545
+ _json({"status": "error", "output": f"no active cell on '{session}'"}); return 1
546
+ cell_id = meta["cell_id"]
547
+
548
+ rpath = _result(session, cell_id)
549
+ if os.path.exists(rpath):
550
+ try:
551
+ with open(rpath) as f: result = json.load(f)
552
+ except (json.JSONDecodeError, OSError):
553
+ # atomic writes make this near-impossible; if it happens,
554
+ # do NOT release lock — state is unknown, let user k int / k kill
555
+ _json({"cell_id": cell_id, "status": "running"})
556
+ return 0
557
+ if result.get("status") == "timeout":
558
+ # mark lock BEFORE consuming result — if this fails, keep result for retry
559
+ if not _update_lock(session, timed_out=True):
560
+ _json({"cell_id": cell_id, "status": "error", "output": "lock update failed; use k int or k kill"})
561
+ return 1
562
+ # safe to consume result now (timed_out written, or non-timeout)
563
+ try: os.unlink(rpath)
564
+ except OSError: pass
565
+ if result.get("status") != "timeout":
566
+ _release(session, cell_id)
567
+ _json(result)
568
+ return 0
569
+
570
+ # check lock state
571
+ meta = _load_cell(session)
572
+
573
+ # no lock, or lock is for a different cell → this cell_id is unknown
574
+ if not meta or meta.get("cell_id") != cell_id:
575
+ _json({"cell_id": cell_id, "status": "error", "output": "unknown cell"})
576
+ return 1
577
+
578
+ # timed_out: command may still be running — only k int / k kill can release
579
+ if meta.get("timed_out"):
580
+ _json({"cell_id": cell_id, "status": "timeout", "output": "use k int or k kill"})
581
+ return 1
582
+
583
+ # check if bg process died (orphaned lock)
584
+ if "bg_pid" in meta:
585
+ pid = meta["bg_pid"]
586
+ try:
587
+ os.kill(pid, 0) # POSIX: check process exists (no signal sent)
588
+ alive = True
589
+ except OSError:
590
+ alive = False
591
+ if not alive:
592
+ _release(session, cell_id)
593
+ _json({"cell_id": cell_id, "status": "error", "output": "watcher died"})
594
+ return 1
595
+
596
+ _json({"cell_id": cell_id, "status": "running"})
597
+ return 0
598
+
599
+
600
+ def cmd_run(session, code, timeout=30, json_out=False):
601
+ if not _session_exists(session):
602
+ _emit(json_out, {"status": "error", "output": f"no session '{session}'"})
603
+ return 1
604
+
605
+ prompt = _session_prompt(session)
606
+ cell_id = uuid.uuid4().hex[:12]
607
+ echo_count = _echo_count(code)
608
+ log_offset = _log_size(session)
609
+
610
+ try:
611
+ lock = CellLock(session, cell_id, log_offset, echo_count)
612
+ except CellBusy as e:
613
+ _emit(json_out, {"status": "error", "output": f"active cell '{e.held_id}'"})
614
+ return 1
615
+
616
+ try:
617
+ with lock:
618
+ try:
619
+ _ensure_pipe(session)
620
+ except Exception as e:
621
+ _emit(json_out, {"status": "error", "output": f"pipe failed: {e}"})
622
+ return 1
623
+
624
+ try:
625
+ _send_code(session, code, prompt)
626
+ except Exception as e:
627
+ _emit(json_out, {"status": "error", "output": f"send failed: {e}"})
628
+ return 1
629
+
630
+ lock.mark_sent()
631
+ result = _stream_process(session, cell_id, log_offset, echo_count, timeout, prompt)
632
+
633
+ if result.get("status") == "timeout":
634
+ lock.mark_keep()
635
+ except (Exception, KeyboardInterrupt):
636
+ # CellLock.__exit__ handled cleanup (interrupt recovery or lock kept)
637
+ msg = "interrupt failed; use k kill" if lock.interrupt_failed else "interrupted"
638
+ _emit(json_out, {"cell_id": cell_id, "status": "error", "output": msg})
639
+ return 1
640
+
641
+ _emit(json_out, result)
642
+ return 0
643
+
644
+
645
+ def cmd_notify(session, message):
646
+ if not _session_exists(session):
647
+ print(f"ERR no session '{session}'"); return 1
648
+ try: parent = open(f"/proc/{os.getppid()}/comm").read().strip()
649
+ except Exception: parent = "?"
650
+ _log_event(session, f"── notify [{parent}@k:{os.getpid()}] {message} ──")
651
+ print(f"OK notified: {message}")
652
+ return 0
653
+
654
+
655
+ def cmd_int(s):
656
+ if not _send_interrupt(s):
657
+ print("ERR interrupt failed; use k kill"); return 1
658
+ # kill bg watcher (if any) before releasing lock
659
+ # prevents old watcher from consuming new cell's output
660
+ meta = _load_cell(s)
661
+ if meta:
662
+ cell_id = meta["cell_id"]
663
+ if _kill_watcher(meta):
664
+ time.sleep(0.2) # let watcher exit
665
+ # write result so poll finds closure — overwrites timeout result too
666
+ _write_result(s, cell_id, {"cell_id": cell_id, "status": "error", "output": "interrupted"})
667
+ _release(s, cell_id)
668
+ print("OK"); return 0
669
+
670
+ def cmd_kill(s):
671
+ # kill bg watcher if running
672
+ meta = _load_cell(s)
673
+ if meta:
674
+ _kill_watcher(meta)
675
+ T.pipe_stop(s); T.kill(s)
676
+ d = os.path.join(CELL_DIR, s)
677
+ if os.path.isdir(d): shutil.rmtree(d, ignore_errors=True)
678
+ print(f"OK killed {s}"); return 0
679
+
680
+ def cmd_ls():
681
+ s = T.ls(); print(s if s else "no sessions"); return 0
682
+
683
+ def cmd_status(session):
684
+ if not _session_exists(session): print(f"ERR no session '{session}'"); return 1
685
+ logpath = _log(session)
686
+ pipe_ok = False
687
+ if os.path.exists(logpath):
688
+ before = os.path.getsize(logpath)
689
+ subprocess.run([TMUX, "send-keys", "-t", session, " ", "BSpace"], capture_output=True)
690
+ time.sleep(0.2)
691
+ pipe_ok = (os.path.getsize(logpath) > before)
692
+ if not pipe_ok:
693
+ T.pipe_start(session, logpath)
694
+ print(f"OK {session} pipe=repaired")
695
+ else:
696
+ print(f"OK {session} pipe=ok")
697
+ return 0
698
+
699
+
700
+ # ═══════════════════════════════════════════
701
+ # WATCH / HISTORY
702
+ # ═══════════════════════════════════════════
703
+
704
+ _NOTIFY_RE = re.compile(r"^── notify \[(.+?)\] (.+) ──$")
705
+ _CELL_RE = re.compile(r"^── cell:([0-9a-f]{12}) (fired|done) ──$")
706
+
707
+ def _filter_line(raw_line):
708
+ clean = ANSI_RE.sub("", raw_line).strip()
709
+ if not clean: return None
710
+ m = _NOTIFY_RE.match(clean)
711
+ if m: return f"\033[33m📢 {m.group(2)}\033[0m \033[2m({m.group(1)})\033[0m"
712
+ m = _CELL_RE.match(clean)
713
+ if m:
714
+ if m.group(2) == "fired": return f"\033[2;36m── {m.group(1)[:8]} ──\033[0m"
715
+ else: return f"\033[2;32m── ✓ ──\033[0m"
716
+ if clean == "..." or clean.startswith("... "): return None
717
+ return ANSI_RE.sub("", raw_line).rstrip()
718
+
719
+ def cmd_watch(session):
720
+ if not _session_exists(session): print(f"ERR no session '{session}'"); return 1
721
+ logpath = _log(session)
722
+ if not os.path.exists(logpath): print(f"ERR no log"); return 1
723
+ print(f"\033[2mwatching {session} (ctrl-c to stop)\033[0m\n")
724
+ try:
725
+ proc = subprocess.Popen(["tail", "-n", "0", "-f", logpath], stdout=subprocess.PIPE, text=True)
726
+ last_printed = None
727
+ for raw_line in proc.stdout:
728
+ r = _filter_line(raw_line)
729
+ if r is not None and r != last_printed:
730
+ print(r)
731
+ last_printed = r
732
+ except KeyboardInterrupt: print(f"\n\033[2mstopped\033[0m")
733
+ finally:
734
+ if proc.poll() is None: proc.kill(); proc.wait()
735
+ return 0
736
+
737
+ def cmd_history(session, n=5):
738
+ if not _session_exists(session): print(f"ERR no session '{session}'"); return 1
739
+ logpath = _log(session)
740
+ if not os.path.exists(logpath): print(f"ERR no log"); return 1
741
+ with open(logpath, "r", errors="replace") as f: raw_lines = f.readlines()
742
+ filtered = [r for line in raw_lines if (r := _filter_line(line)) is not None]
743
+ # dedup consecutive identical lines (frame delimiter noise)
744
+ deduped = []
745
+ for line in filtered:
746
+ if not deduped or line.strip() != deduped[-1].strip():
747
+ deduped.append(line)
748
+ for line in deduped[-n * 5:]: print(line)
749
+ return 0
750
+
751
+
752
+ # ═══════════════════════════════════════════
753
+ # MAIN
754
+ # ═══════════════════════════════════════════
755
+
756
+ def main():
757
+ args = sys.argv[1:]
758
+ if not args or args[0] in ("-h", "--help"):
759
+ print(__doc__.strip()); return 0
760
+ verb, rest = args[0], args[1:]
761
+
762
+ if verb == "_bg" and len(rest) >= 5:
763
+ session, cell_id, offset, echo, tout = rest[:5]
764
+ _validate_name(session)
765
+ prompt = rest[5] if len(rest) > 5 else None
766
+ _stream_process(session, cell_id, int(offset), int(echo), timeout=int(tout), prompt=prompt)
767
+ return 0
768
+
769
+ if verb == "new" and rest:
770
+ prompt = None; cmd_parts = []
771
+ for a in rest[1:]:
772
+ if a.startswith("--prompt="): prompt = a[len("--prompt="):]
773
+ else: cmd_parts.append(a)
774
+ return cmd_new(rest[0], cmd_parts, prompt)
775
+ if verb == "kill" and rest:
776
+ _validate_name(rest[0]); return cmd_kill(rest[0])
777
+ if verb == "ls": return cmd_ls()
778
+
779
+ if verb in ("run", "await"):
780
+ timeout, json_out = 30, False
781
+ while rest and rest[0].startswith("-"):
782
+ if rest[0] == "-t":
783
+ if len(rest) < 2: print("usage: k run [-j] [-t N] [session] <code>"); return 1
784
+ timeout = int(rest[1]); rest = rest[2:]
785
+ elif rest[0] == "-j": json_out = True; rest = rest[1:]
786
+ else: break
787
+ if len(rest) >= 2: s, c = rest[0], rest[1]; _validate_name(s)
788
+ elif len(rest) == 1: s, c = _resolve(), rest[0]
789
+ else: print("usage: k run [-j] [-t N] [session] <code>"); return 1
790
+ if not s: print("ERR: no session found."); return 1
791
+ return cmd_run(s, c, timeout, json_out)
792
+
793
+ if verb == "fire" and rest:
794
+ timeout = 300
795
+ while rest and rest[0].startswith("-"):
796
+ if rest[0] == "-t":
797
+ if len(rest) < 2: print("usage: k fire [-t N] [session] <code>"); return 1
798
+ timeout = int(rest[1]); rest = rest[2:]
799
+ else: break
800
+ if len(rest) >= 2: s, c = rest[0], rest[1]; _validate_name(s)
801
+ else: s, c = _resolve(), rest[0]
802
+ if not s: print("ERR: no session found."); return 1
803
+ return cmd_fire(s, c, timeout)
804
+
805
+ if verb == "poll":
806
+ s = _resolve(rest[0] if rest else None)
807
+ if not s: print("ERR: no session found."); return 1
808
+ return cmd_poll(s, rest[1] if len(rest) >= 2 else None)
809
+
810
+ if verb == "notify" and rest:
811
+ if len(rest) >= 2 and T.has(rest[0]):
812
+ _validate_name(rest[0]); s, msg = rest[0], " ".join(rest[1:])
813
+ else: s, msg = _resolve(), " ".join(rest)
814
+ if not s: print("ERR: no session found."); return 1
815
+ return cmd_notify(s, msg)
816
+
817
+ if verb == "int":
818
+ s = _resolve(rest[0] if rest else None)
819
+ if not s: print("ERR: no session found."); return 1
820
+ return cmd_int(s)
821
+ if verb == "status":
822
+ s = _resolve(rest[0] if rest else None)
823
+ if not s: print("ERR: no session found."); return 1
824
+ return cmd_status(s)
825
+ if verb == "watch":
826
+ s = _resolve(rest[0] if rest else None)
827
+ if not s: print("ERR: no session found."); return 1
828
+ return cmd_watch(s)
829
+ if verb == "history":
830
+ n = 5
831
+ if rest and rest[0] == "-n":
832
+ if len(rest) < 2: print("usage: k history [-n N] [session]"); return 1
833
+ n = int(rest[1]); rest = rest[2:]
834
+ s = _resolve(rest[0] if rest else None)
835
+ if not s: print("ERR: no session found."); return 1
836
+ return cmd_history(s, n)
837
+
838
+ print(__doc__.strip()); return 1
839
+
840
+ if __name__ == "__main__": sys.exit(main())
agent_tty/monitor.py ADDED
@@ -0,0 +1,222 @@
1
+ #!/usr/bin/env python3
2
+ r"""
3
+ km -- interrupt-driven monitor for k sessions.
4
+
5
+ Watches a tmux session via pipe-pane (not polling).
6
+ Outputs structured JSON events to stdout.
7
+ Each stdout line -> one Monitor notification -> agent interrupt.
8
+
9
+ Usage:
10
+ km <session> [cell_id] [-1]
11
+
12
+ session tmux session to watch
13
+ cell_id only match this cell (optional, matches any cell if omitted)
14
+ -1 exit after first completion (one-shot / .then())
15
+
16
+ Examples:
17
+ km work abc123 -1 <- await one cell
18
+ km work -1 <- await any cell completion
19
+ km work <- continuous, all completions
20
+
21
+ Architecture:
22
+ tmux pipe-pane -> log file -> tail -f -> parse -> JSON event -> stdout
23
+ No polling. Interrupt-driven end to end.
24
+ This is the .then() callback mechanism.
25
+ """
26
+
27
+ import sys
28
+ import os
29
+ import re
30
+ import json
31
+ import signal
32
+ import subprocess
33
+ import shutil
34
+
35
+ from datetime import datetime, timezone
36
+
37
+ TMUX = shutil.which("tmux") or "tmux"
38
+
39
+ _SAFE_NAME = re.compile(r'^[A-Za-z0-9_.-]+$')
40
+ def _validate_name(s):
41
+ if not s or not _SAFE_NAME.match(s) or '..' in s:
42
+ print(f"km: invalid session name: {s!r}", file=sys.stderr)
43
+ sys.exit(1)
44
+
45
+ ANSI_RE = re.compile(
46
+ r"\x1b\[[0-9;]*[a-zA-Z]"
47
+ r"|\x1b\[<[0-9;]*[mM]"
48
+ r"|\x1b\[\?[0-9;]*[hlsr]"
49
+ r"|\x1b\][^\x07]*\x07"
50
+ r"|\x1b\][^\x1b]*\x1b\\"
51
+ r"|\x1b[()][0-9A-B]"
52
+ r"|\x1b[>=]"
53
+ r"|\x1b\x50[^\x1b]*\x1b\\"
54
+ r"|\x08|\r"
55
+ )
56
+
57
+ # cell event patterns (written directly to log by k)
58
+ # fired: ── cell:<hex12> fired ──
59
+ # done: ── cell:<hex12> done ──
60
+ # notify: ── notify [...] <message> ──
61
+ START_RE = re.compile(r"^── cell:([0-9a-f]{12}) fired ──$")
62
+ END_RE = re.compile(r"^── cell:([0-9a-f]{12}) done ──$")
63
+ NOTIFY_RE = re.compile(r"^── notify \[(.+?)\] (.+) ──$")
64
+
65
+
66
+ def _emit(d: dict):
67
+ """One JSON line to stdout = one agent interrupt."""
68
+ d["ts"] = datetime.now(timezone.utc).isoformat()
69
+ sys.stdout.write(json.dumps(d, ensure_ascii=False) + "\n")
70
+ sys.stdout.flush()
71
+
72
+
73
+ class E:
74
+ """km event factory."""
75
+
76
+ @staticmethod
77
+ def started(cell_id: str, session: str):
78
+ _emit({"cell_id": cell_id, "session": session, "status": "fired"})
79
+
80
+ @staticmethod
81
+ def completed(cell_id: str, session: str):
82
+ _emit({"cell_id": cell_id, "session": session, "status": "done"})
83
+
84
+ @staticmethod
85
+ def notify(session: str, who: str, message: str):
86
+ _emit({"session": session, "status": "notify", "from": who, "message": message})
87
+
88
+ @staticmethod
89
+ def closed(session: str):
90
+ _emit({"session": session, "status": "closed"})
91
+
92
+ @staticmethod
93
+ def error(session: str, message: str):
94
+ _emit({"session": session, "status": "error", "message": message})
95
+
96
+
97
+ CELL_DIR = "/tmp/k_cells"
98
+
99
+ def session_log_path(session: str) -> str:
100
+ return os.path.join(CELL_DIR, session, "_output.log")
101
+
102
+
103
+ def start_pipe(session: str) -> str:
104
+ """
105
+ (Re)start pipe-pane. Idempotent — replaces dead/existing pipe.
106
+ """
107
+ logfile = session_log_path(session)
108
+
109
+ os.makedirs(os.path.join(CELL_DIR, session), exist_ok=True)
110
+
111
+ open(logfile, "a").close()
112
+ subprocess.run(
113
+ [TMUX, "pipe-pane", "-t", session, f"cat >> '{logfile}'"],
114
+ check=True,
115
+ )
116
+
117
+ return logfile
118
+
119
+
120
+ def stop_pipe(session: str, logfile: str, tail_proc=None):
121
+ """Cleanup: kill tail. Don't stop pipe-pane or remove log — k owns those."""
122
+ if tail_proc and tail_proc.poll() is None:
123
+ tail_proc.kill()
124
+ tail_proc.wait()
125
+ # DON'T stop pipe-pane — k may still need it
126
+ # DON'T remove log — k owns the session directory
127
+
128
+
129
+ def monitor(session: str, cell_id: str = None, oneshot: bool = False):
130
+ # verify session
131
+ r = subprocess.run([TMUX, "has-session", "-t", session], capture_output=True)
132
+ if r.returncode != 0:
133
+ E.error(session, f"no session '{session}'")
134
+ return 1
135
+
136
+ logfile = start_pipe(session)
137
+ tail_proc = None
138
+
139
+ def cleanup(*_):
140
+ stop_pipe(session, logfile, tail_proc)
141
+
142
+ signal.signal(signal.SIGTERM, lambda *_: (cleanup(), sys.exit(0)))
143
+ signal.signal(signal.SIGINT, lambda *_: (cleanup(), sys.exit(0)))
144
+
145
+ try:
146
+ # tail -f: interrupt-driven (inotify on linux, kqueue on mac)
147
+ tail_proc = subprocess.Popen(
148
+ ["tail", "-n", "0", "-f", logfile],
149
+ stdout=subprocess.PIPE,
150
+ stderr=subprocess.DEVNULL,
151
+ text=True,
152
+ bufsize=1, # line-buffered
153
+ )
154
+
155
+ # track cells we've seen start (to pair start/end)
156
+ active_cells = set()
157
+
158
+ for raw_line in tail_proc.stdout:
159
+ line = ANSI_RE.sub("", raw_line).strip()
160
+ if not line:
161
+ continue
162
+
163
+ # check start
164
+ m = START_RE.match(line)
165
+ if m:
166
+ cid = m.group(1)
167
+ if cell_id is None or cid == cell_id:
168
+ active_cells.add(cid)
169
+ E.started(cid, session)
170
+ continue
171
+
172
+ # check done
173
+ m = END_RE.match(line)
174
+ if m:
175
+ cid = m.group(1)
176
+ if cell_id is None or cid == cell_id:
177
+ active_cells.discard(cid)
178
+ E.completed(cid, session)
179
+ if oneshot:
180
+ return 0
181
+ continue
182
+
183
+ # check notify
184
+ m = NOTIFY_RE.match(line)
185
+ if m:
186
+ who, message = m.group(1), m.group(2)
187
+ E.notify(session, who, message)
188
+ continue
189
+
190
+ # tail ended (session died?)
191
+ E.closed(session)
192
+ return 1
193
+
194
+ finally:
195
+ cleanup()
196
+
197
+
198
+ def main():
199
+ args = sys.argv[1:]
200
+ if not args or args[0] in ("-h", "--help"):
201
+ print(__doc__.strip())
202
+ return 0
203
+
204
+ session = args[0]
205
+ _validate_name(session)
206
+ cell_id = None
207
+ oneshot = False
208
+
209
+ for arg in args[1:]:
210
+ if arg == "-1":
211
+ oneshot = True
212
+ elif re.match(r"^[0-9a-f]{12}$", arg):
213
+ cell_id = arg
214
+ else:
215
+ print(f"unknown arg: {arg}", file=sys.stderr)
216
+ return 1
217
+
218
+ return monitor(session, cell_id, oneshot)
219
+
220
+
221
+ if __name__ == "__main__":
222
+ sys.exit(main())
@@ -0,0 +1,182 @@
1
+ Metadata-Version: 2.4
2
+ Name: agent-tty
3
+ Version: 0.1.0
4
+ Summary: Persistent terminal sessions for AI agents
5
+ Project-URL: Homepage, https://github.com/rangersui/agent-tty
6
+ Project-URL: Repository, https://github.com/rangersui/agent-tty
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: agent,ai,repl,terminal,tmux
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: POSIX
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Software Development :: Libraries
17
+ Classifier: Topic :: System :: Shells
18
+ Requires-Python: >=3.8
19
+ Description-Content-Type: text/markdown
20
+
21
+ # agent-tty
22
+
23
+ Persistent terminal sessions for AI agents. Drives tmux, returns JSON.
24
+
25
+ The package is `agent-tty`. The CLI command is `k`, intentionally short to minimise token overhead in agent tool calls. `km` is the companion event monitor.
26
+
27
+ **Requires tmux 3.0+** — k drives tmux for PTY multiplexing; it does not bundle or replace it.
28
+
29
+ ## Quick Start
30
+
31
+ ```bash
32
+ k new work bash
33
+ k run -j work "echo hello"
34
+ # {"cell_id":"...","status":"done","output":"hello"}
35
+
36
+ k new py python3 -i # Python 3.12 and below
37
+ k new py "env PYTHON_BASIC_REPL=1 python3 -i" # Python 3.13+ (disables _pyrepl auto-indent)
38
+ k run -j py "print(42)"
39
+ ```
40
+
41
+ ## Install
42
+
43
+ Requires: **Python 3.8+**, **tmux 3.0+**
44
+
45
+ ```bash
46
+ pip install agent-tty # → k, km, agent-tty in PATH
47
+ ```
48
+
49
+ Or without pip:
50
+
51
+ ```bash
52
+ git clone <repo> && cd agent-tty
53
+ ./scripts/k --help # works immediately (dev shim)
54
+ ```
55
+
56
+ Or symlink into PATH:
57
+
58
+ ```bash
59
+ ln -sf "$(pwd)/scripts/k" /usr/local/bin/k
60
+ ln -sf "$(pwd)/scripts/km" /usr/local/bin/km
61
+ ```
62
+
63
+ ## Commands
64
+
65
+ ```
66
+ k new <session> [cmd...] [--prompt="x"] spawn (default: bash)
67
+ k new <session> <cmd> --prompt=./hook hook mode
68
+ k fire [-t N] [session] <code> async fire (default 300s)
69
+ k poll [session] [cell_id] poll (O(1))
70
+ k run [-j] [-t N] [session] <code> sync (default 30s)
71
+ k await ... alias for run
72
+ k notify [session] <message> notification
73
+ k int [session] ctrl-c
74
+ k kill <session> kill + cleanup
75
+ k ls list sessions
76
+ k status [session] health check
77
+ k watch [session] live filtered view
78
+ k history [-n N] [session] last N×5 lines (default 5)
79
+ ```
80
+
81
+ ## Frame Detection
82
+
83
+ Three modes via `--prompt`:
84
+
85
+ | --prompt= | mode | how |
86
+ | ------------- | ------ | ------------------------------------------- |
87
+ | *(not set)* | repeat | 5 empty Enters → 5 identical lines → done |
88
+ | `"(gdb)"` | exact | match prompt string |
89
+ | `./hook.py` | hook | stdin lines → hook exit → done |
90
+
91
+ Hook protocol: k feeds ANSI-stripped lines to stdin. Hook exits = frame end. Hook paths must include a path separator (`/`, or `\` on Windows). Path is canonicalised to absolute at `k new` time; hook must exist and be executable.
92
+
93
+ ## How It Works
94
+
95
+ ```
96
+ k fire "echo hello"
97
+ |
98
+ +-- acquires lock (rejected fire = zero side effects)
99
+ +-- sends code via paste-buffer (atomic)
100
+ +-- sends 5 frame Enters (repeat mode only)
101
+ +-- starts background stream processor
102
+ |
103
+ stream processor tails log:
104
+ ECHOING: skip echo_count lines
105
+ OUTPUT: collect lines
106
+ DONE: 5 identical lines / prompt match / hook exit
107
+ |
108
+ writes result file -> exits
109
+ |
110
+ k poll
111
+ +-- checks result file (O(1))
112
+ +-- returns JSON
113
+ ```
114
+
115
+ ## Safety
116
+
117
+ | invariant | mechanism |
118
+ | ------------------------ | ----------------------------------------------------------------------------------------------------------- |
119
+ | one cell per session | O_EXCL lock, acquired before send |
120
+ | timeout keeps lock | lock marked `timed_out`; subsequent polls say `use k int or k kill` |
121
+ | orphan recovery | bg PID in lock, poll checks `os.kill(pid, 0)` (POSIX) |
122
+ | no line-wrap skew | tmux width 10000 |
123
+ | atomic send | per-session named paste-buffer `k_{session}` |
124
+ | ctrl-c safe | kills watcher, writes `{"status": "error", "output": "interrupted"}`, re-sends frame enters (repeat only) |
125
+ | session name validation | `[A-Za-z0-9_.-]+`, no `..`, no path traversal |
126
+ | idempotent pipe restart | pipe-pane replaced on every fire/run |
127
+ | atomic result writes | tmp + fsync +`os.replace` — poll never reads partial JSON |
128
+ | no output classification | "done" = prompt appeared, not success |
129
+
130
+ ## JSON Schema (k)
131
+
132
+ ```
133
+ fired: {"cell_id": "...", "status": "fired"}
134
+ running: {"cell_id": "...", "status": "running"}
135
+ done: {"cell_id": "...", "status": "done", "output": "..."}
136
+ timeout: {"cell_id": "...", "status": "timeout", "output": ""}
137
+ timeout(2+): {"cell_id": "...", "status": "timeout", "output": "use k int or k kill"}
138
+ error: {"status": "error", "output": "..."}
139
+ cell error: {"cell_id": "...", "status": "error", "output": "..."}
140
+ ```
141
+
142
+ Errors without `cell_id`: `no session 'x'`, `active cell 'x'`, `pipe failed: ...`, `send failed: ...`, `no active cell on 'x'`.
143
+ Errors with `cell_id`: `interrupted`, `unknown cell`, `watcher died`, `lock update failed; use k int or k kill`, `interrupt failed; use k kill`.
144
+
145
+ ## km — event monitor
146
+
147
+ ```
148
+ km <session> [cell_id] [-1]
149
+ ```
150
+
151
+ Watches a session via pipe-pane. Each stdout line is one JSON event. `-1` exits after first completion (one-shot `.then()`).
152
+
153
+ ```
154
+ fired: {"cell_id": "...", "session": "...", "status": "fired", "ts": "..."}
155
+ done: {"cell_id": "...", "session": "...", "status": "done", "ts": "..."}
156
+ notify: {"session": "...", "status": "notify", "from": "...", "message": "...", "ts": "..."}
157
+ closed: {"session": "...", "status": "closed", "ts": "..."}
158
+ error: {"session": "...", "status": "error", "message": "...", "ts": "..."}
159
+ ```
160
+
161
+ ## Testing
162
+
163
+ ```bash
164
+ python tests/test_contracts.py # static code contracts, no tmux
165
+ python tests/test_docs.py # README/SKILL drift, no tmux
166
+ bash tests/test.sh # 34 tests (32 without gdb), runtime smoke suite
167
+ python tests/test_regressions.py # targeted audit regressions
168
+ python tests/run_all.py # all suites
169
+ ```
170
+
171
+ ## Files
172
+
173
+ ```
174
+ src/agent_tty/cli.py k — main script
175
+ src/agent_tty/monitor.py km — event monitor
176
+ scripts/k, scripts/km dev shims (no pip install needed)
177
+ pyproject.toml pip install agent-tty → agent-tty, k, km in PATH
178
+ tests/test.sh runtime smoke suite
179
+ tests/*.py static, docs, and regression suites
180
+ SKILL.md agent reference
181
+ EXAMPLES.md patterns + philosophy
182
+ ```
@@ -0,0 +1,9 @@
1
+ agent_tty/__init__.py,sha256=ynTguMCn_tGobunPWeph82UXwvOlnf-Epqv9Yq1xpBo,83
2
+ agent_tty/__main__.py,sha256=bwB8LagFCVMW8I0yAv91PwFT-Pt8XfsVyke-MA63TyE,91
3
+ agent_tty/cli.py,sha256=iq4J1_driGAQ1dAWGYTiPcdjC9_dAwMBB_tWGWeCYWg,32438
4
+ agent_tty/monitor.py,sha256=8R-XSFAlHKXPtzyND17E2suaIKQQbiucZW8wpO7nVDM,6238
5
+ agent_tty-0.1.0.dist-info/METADATA,sha256=9h2fciIhUb-E5uuNpXPvPbuakk9y8OTW7UASI75Jcbw,7559
6
+ agent_tty-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
7
+ agent_tty-0.1.0.dist-info/entry_points.txt,sha256=5Fq_nVWGjMBSnaLZDyH9Ujd1Se6UEt4Pepn8_4qNxjA,100
8
+ agent_tty-0.1.0.dist-info/licenses/LICENSE,sha256=9dXiczvbxmo-GIaoGRPohp_nQv8thQJeyLYKavHM934,1068
9
+ agent_tty-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,4 @@
1
+ [console_scripts]
2
+ agent-tty = agent_tty.cli:main
3
+ k = agent_tty.cli:main
4
+ km = agent_tty.monitor:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ranger Chen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.