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 +2 -0
- agent_tty/__main__.py +3 -0
- agent_tty/cli.py +840 -0
- agent_tty/monitor.py +222 -0
- agent_tty-0.1.0.dist-info/METADATA +182 -0
- agent_tty-0.1.0.dist-info/RECORD +9 -0
- agent_tty-0.1.0.dist-info/WHEEL +4 -0
- agent_tty-0.1.0.dist-info/entry_points.txt +4 -0
- agent_tty-0.1.0.dist-info/licenses/LICENSE +21 -0
agent_tty/__init__.py
ADDED
agent_tty/__main__.py
ADDED
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,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.
|