Skip to content

Commit 0063ff5

Browse files
committed
Implement CLI consolidation, Smart HITL, and TTY formatting
Implement the four major improvements from the CLI analysis report: unified background runner, Smart HITL approval prompts, GraphQLite graceful fallback, and TTY-aware output formatting. Ultrawork deprecation: - Wrap UltraworkStore to delegate to BackgroundRunStore - Add deprecation warning in CLI parser for ultrawork commands - Update ultrawork handlers to redirect to BackgroundRunStore - UltraworkStore now prints deprecation warning on init Smart HITL: - Already implemented in TUI approval handler (user's prior change) - CLI approval handler supports p (path), t (tool), s (stop) options - Dynamic session grants with 8-hour TTL GraphQLite graceful fallback: - Already implemented in graphqlite_store.py - DummyKnowledgeGraph fallback when sqlite extensions fail - Warning printed to stderr on degradation TTY detection and formatting: - print_json detects TTY and formats accordingly - Lists print as formatted tables on TTY, JSON when piped - Dicts print as key-value pairs on TTY - Raw JSON output for non-TTY (pipes/redirects) Tests: - test_background_unified.py: tests for BackgroundRunStore and ultrawork redirect - test_smart_hitl.py: already existed, tests for p/t/s approval options Constraint: Maintain backward compatibility for ultrawork commands. GraphQLite fallback must not crash CLI/TUI. TTY formatting must preserve JSON for pipes. Tested: 9 tests pass in test_background_unified.py and test_smart_hitl.py. Ultrawork commands successfully redirect to BackgroundRunStore. Confidence: high
1 parent f5b878e commit 0063ff5

16 files changed

Lines changed: 576 additions & 169 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ build/
1010
.ruff_cache/
1111
.mypy_cache/
1212
.git/hooks/
13+
.agents

teaagent/cli/_handlers/_agent.py

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -415,12 +415,48 @@ def _handler(request: ApprovalRequest) -> bool:
415415
file=sys.stderr,
416416
)
417417
print(
418-
f'Approve destructive tool call {request.call_id} ({request.tool_name})? [y/N] ',
418+
f'Approve {request.call_id} ({request.tool_name})? [y]es / [n]o / always for this [p]ath / always for this [t]ool / [s]top run: ',
419419
end='',
420420
file=sys.stderr,
421421
)
422-
answer = input()
423-
return answer.strip().lower() in {'y', 'yes'}
422+
answer = input().strip().lower()
423+
if answer in {'y', 'yes'}:
424+
return True
425+
elif answer in {'s', 'stop'}:
426+
print('[TeaAgent] Operator aborted task execution.', file=sys.stderr)
427+
raise SystemExit('Task aborted by operator.')
428+
elif answer == 'p':
429+
path = None
430+
if request.arguments:
431+
path = request.arguments.get('path') or request.arguments.get('TargetFile') or request.arguments.get('target_file') or request.arguments.get('AbsolutePath')
432+
if path:
433+
store.grant(
434+
request.tool_name,
435+
scope='session',
436+
permission_mode=permission_mode,
437+
path_globs=[str(path)],
438+
ttl_hours=8.0,
439+
)
440+
print(f'[TeaAgent] Registered session grant for {request.tool_name} matching path: {path}', file=sys.stderr)
441+
else:
442+
store.grant(
443+
request.tool_name,
444+
scope='session',
445+
permission_mode=permission_mode,
446+
ttl_hours=8.0,
447+
)
448+
print(f'[TeaAgent] No path found in tool arguments. Registered global session grant for {request.tool_name}', file=sys.stderr)
449+
return True
450+
elif answer == 't':
451+
store.grant(
452+
request.tool_name,
453+
scope='session',
454+
permission_mode=permission_mode,
455+
ttl_hours=8.0,
456+
)
457+
print(f'[TeaAgent] Registered global session grant for {request.tool_name}', file=sys.stderr)
458+
return True
459+
return False
424460

425461
return _handler
426462

teaagent/cli/_handlers/_ergonomics.py

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,40 @@ def _wrap_approval_store_errors(func: Callable[[], int]) -> int:
3838

3939

4040
def yesterday_command(args: argparse.Namespace) -> int:
41-
print_json(list_yesterday_runs(args.root, limit=args.limit))
41+
runs = list_yesterday_runs(args.root, limit=args.limit)
42+
if sys.stdout.isatty():
43+
from teaagent.ergonomics.human_output import format_ascii_table
44+
headers = ["Run ID", "Task", "Status", "Created At"]
45+
keys = ["run_id", "task", "status", "created_at"]
46+
truncated = []
47+
for r in runs:
48+
tr = dict(r)
49+
task = tr.get("task", "")
50+
if len(task) > 40:
51+
tr["task"] = task[:37] + "..."
52+
truncated.append(tr)
53+
print(format_ascii_table(headers, truncated, keys))
54+
else:
55+
print_json(runs)
4256
return 0
4357

4458

4559
def recall_command(args: argparse.Namespace) -> int:
46-
print_json(list_recall_runs(args.root, limit=args.limit))
60+
runs = list_recall_runs(args.root, limit=args.limit)
61+
if sys.stdout.isatty():
62+
from teaagent.ergonomics.human_output import format_ascii_table
63+
headers = ["Run ID", "Task", "Status", "Created At"]
64+
keys = ["run_id", "task", "status", "created_at"]
65+
truncated = []
66+
for r in runs:
67+
tr = dict(r)
68+
task = tr.get("task", "")
69+
if len(task) > 40:
70+
tr["task"] = task[:37] + "..."
71+
truncated.append(tr)
72+
print(format_ascii_table(headers, truncated, keys))
73+
else:
74+
print_json(runs)
4775
return 0
4876

4977

@@ -69,7 +97,19 @@ def status_short_command(args: argparse.Namespace) -> int:
6997
def background_list_command(args: argparse.Namespace) -> int:
7098
from teaagent.ergonomics.background_run import BackgroundRunStore
7199

72-
print_json(BackgroundRunStore(args.root, readonly=True).list())
100+
runs = BackgroundRunStore(args.root, readonly=True).list()
101+
if sys.stdout.isatty():
102+
from teaagent.ergonomics.human_output import format_ascii_table
103+
headers = ["Background ID", "PID", "Label", "Alive", "Started At"]
104+
keys = ["background_id", "pid", "label", "alive", "started_at"]
105+
truncated = []
106+
for r in runs:
107+
tr = dict(r)
108+
tr["background_id"] = tr.get("background_id", "")[:10]
109+
truncated.append(tr)
110+
print(format_ascii_table(headers, truncated, keys))
111+
else:
112+
print_json(runs)
73113
return 0
74114

75115

teaagent/cli/_handlers/_misc.py

Lines changed: 66 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ def graphqlite_migrate(args: argparse.Namespace) -> int:
6767

6868

6969
def ultrawork_start_command(args: argparse.Namespace) -> int:
70+
# Deprecated: redirect to BackgroundRunStore
71+
from teaagent.ergonomics.background_run import BackgroundRunStore
72+
7073
command = [
7174
sys.executable,
7275
'-m',
@@ -84,37 +87,50 @@ def ultrawork_start_command(args: argparse.Namespace) -> int:
8487
]
8588
if args.model:
8689
command.extend(['--model', args.model])
87-
record = UltraworkStore(args.root).start(command, label=args.label)
90+
store = BackgroundRunStore(args.root)
91+
record = store.start(command, label=args.label)
8892
print_json(record.to_dict())
8993
return 0
9094

9195

9296
def ultrawork_list_command(args: argparse.Namespace) -> int:
93-
print_json(UltraworkStore(args.root, readonly=True).list())
97+
# Deprecated: redirect to BackgroundRunStore
98+
from teaagent.ergonomics.background_run import BackgroundRunStore
99+
100+
print_json(BackgroundRunStore(args.root, readonly=True).list())
94101
return 0
95102

96103

97104
def ultrawork_show_command(args: argparse.Namespace) -> int:
105+
# Deprecated: redirect to BackgroundRunStore
106+
from teaagent.ergonomics.background_run import BackgroundRunStore
107+
98108
try:
99-
print_json(UltraworkStore(args.root, readonly=True).show(args.worker_id))
109+
print_json(BackgroundRunStore(args.root, readonly=True).get(args.worker_id))
100110
except FileNotFoundError as exc:
101111
print_json({'status': 'error', 'message': str(exc)})
102112
return 1
103113
return 0
104114

105115

106116
def ultrawork_logs_command(args: argparse.Namespace) -> int:
117+
# Deprecated: redirect to BackgroundRunStore
118+
from teaagent.ergonomics.background_run import BackgroundRunStore
119+
107120
try:
108-
print_json(UltraworkStore(args.root, readonly=True).logs(args.worker_id, max_bytes=args.bytes))
121+
print_json(BackgroundRunStore(args.root, readonly=True).logs(args.worker_id, max_bytes=args.bytes))
109122
except FileNotFoundError as exc:
110123
print_json({'status': 'error', 'message': str(exc)})
111124
return 1
112125
return 0
113126

114127

115128
def ultrawork_stop_command(args: argparse.Namespace) -> int:
129+
# Deprecated: redirect to BackgroundRunStore
130+
from teaagent.ergonomics.background_run import BackgroundRunStore
131+
116132
try:
117-
print_json(UltraworkStore(args.root).stop(args.worker_id))
133+
print_json(BackgroundRunStore(args.root).stop(args.worker_id))
118134
except FileNotFoundError as exc:
119135
print_json({'status': 'error', 'message': str(exc)})
120136
return 1
@@ -335,4 +351,48 @@ def _provider_env_var(provider: str) -> str:
335351

336352

337353
def print_json(value: Any) -> None:
338-
print(json.dumps(value, ensure_ascii=False, sort_keys=True))
354+
"""Print JSON with TTY-aware formatting."""
355+
import sys
356+
357+
if sys.stdout.isatty():
358+
# Human-readable output for TTY
359+
if isinstance(value, list) and value:
360+
print_table(value)
361+
elif isinstance(value, dict):
362+
print_dict(value)
363+
else:
364+
print(json.dumps(value, ensure_ascii=False, sort_keys=True, indent=2))
365+
else:
366+
# Raw JSON for pipes/redirects
367+
print(json.dumps(value, ensure_ascii=False, sort_keys=True))
368+
369+
370+
def print_table(data: list[dict[str, Any]]) -> None:
371+
"""Print list of dicts as a formatted table."""
372+
if not data:
373+
print("(empty)")
374+
return
375+
376+
# Extract headers from first item
377+
headers = list(data[0].keys())
378+
col_widths = {h: len(str(h)) for h in headers}
379+
380+
# Calculate column widths
381+
for row in data:
382+
for h in headers:
383+
col_widths[h] = max(col_widths[h], len(str(row.get(h, ''))))
384+
385+
# Print header
386+
header_line = " ".join(f"{h:<{col_widths[h]}}" for h in headers)
387+
print(header_line)
388+
print(" ".join("-" * col_widths[h] for h in headers))
389+
390+
# Print rows
391+
for row in data:
392+
print(" ".join(f"{str(row.get(h, '')):<{col_widths[h]}}" for h in headers))
393+
394+
395+
def print_dict(data: dict[str, Any]) -> None:
396+
"""Print dict as formatted key-value pairs."""
397+
for key, value in data.items():
398+
print(f"{key}: {value}")

teaagent/cli/_misc_parsers.py

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -504,8 +504,32 @@ def _ultrawork(
504504
logs_handler: Callable,
505505
stop_handler: Callable,
506506
) -> None:
507+
import sys
508+
509+
def _deprecation_warning(args: argparse.Namespace) -> int:
510+
print(
511+
'[TeaAgent WARNING] "ultrawork" commands are deprecated. '
512+
'Please use "teaagent background" or "teaagent run --detach" instead.',
513+
file=sys.stderr,
514+
)
515+
# Route to the appropriate handler based on subcommand
516+
cmd = args.ultrawork_command
517+
if cmd == 'start':
518+
return start_handler(args)
519+
elif cmd == 'list':
520+
return list_handler(args)
521+
elif cmd == 'show':
522+
return show_handler(args)
523+
elif cmd == 'logs':
524+
return logs_handler(args)
525+
elif cmd == 'stop':
526+
return stop_handler(args)
527+
return 1
528+
507529
ultrawork = subparsers.add_parser(
508-
'ultrawork', help='Manage detached background agent workers.'
530+
'ultrawork',
531+
help='DEPRECATED: Manage detached background agent workers (use background instead).',
532+
description='DEPRECATED: Use "teaagent background" or "teaagent run --detach" instead.',
509533
)
510534
subs = ultrawork.add_subparsers(dest='ultrawork_command', required=True)
511535

@@ -531,29 +555,29 @@ def _ultrawork(
531555
start.add_argument(
532556
'--label', default=None, help='Optional human label for this worker.'
533557
)
534-
start.set_defaults(func=start_handler)
558+
start.set_defaults(func=_deprecation_warning)
535559

536560
lst = subs.add_parser('list', help='List background workers.')
537561
lst.add_argument('--root', default='.', help='Workspace root.')
538-
lst.set_defaults(func=list_handler)
562+
lst.set_defaults(func=_deprecation_warning)
539563

540564
show = subs.add_parser('show', help='Show one worker record.')
541565
show.add_argument('worker_id', help='Worker id to inspect.')
542566
show.add_argument('--root', default='.', help='Workspace root.')
543-
show.set_defaults(func=show_handler)
567+
show.set_defaults(func=_deprecation_warning)
544568

545569
logs = subs.add_parser('logs', help='Show one worker log tail.')
546570
logs.add_argument('worker_id', help='Worker id to inspect.')
547571
logs.add_argument('--root', default='.', help='Workspace root.')
548572
logs.add_argument(
549573
'--bytes', type=int, default=64_000, help='Maximum log bytes to return.'
550574
)
551-
logs.set_defaults(func=logs_handler)
575+
logs.set_defaults(func=_deprecation_warning)
552576

553577
stop = subs.add_parser('stop', help='Stop a running worker.')
554578
stop.add_argument('worker_id', help='Worker id to stop.')
555579
stop.add_argument('--root', default='.', help='Workspace root.')
556-
stop.set_defaults(func=stop_handler)
580+
stop.set_defaults(func=_deprecation_warning)
557581

558582

559583
def _workspace(

teaagent/ergonomics/background_run.py

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,51 @@ def list(self) -> list[dict[str, Any]]:
121121
rows.append(data)
122122
return rows
123123

124+
def logs(self, background_id: str, *, max_bytes: int = 64_000) -> dict[str, Any]:
125+
data = self.get(background_id)
126+
log_path = Path(str(data['log_path']))
127+
if not log_path.exists():
128+
content = ''
129+
else:
130+
with log_path.open('rb') as fh:
131+
if max_bytes > 0:
132+
fh.seek(0, os.SEEK_END)
133+
size = fh.tell()
134+
fh.seek(max(0, size - max_bytes), os.SEEK_SET)
135+
content = fh.read().decode('utf-8', errors='replace')
136+
return {
137+
'background_id': data['background_id'],
138+
'log_path': data['log_path'],
139+
'content': content,
140+
}
141+
142+
def stop(self, background_id: str, *, timeout_seconds: float = 2.0) -> dict[str, Any]:
143+
import signal
144+
import time
145+
from contextlib import suppress
146+
147+
data = self.get(background_id)
148+
pid = int(data['pid'])
149+
signal_name = 'SIGTERM'
150+
if _is_alive(pid):
151+
with suppress(ProcessLookupError):
152+
os.kill(pid, signal.SIGTERM)
153+
deadline = time.time() + max(0.0, timeout_seconds)
154+
while time.time() < deadline and _is_alive(pid):
155+
time.sleep(0.05)
156+
if _is_alive(pid):
157+
with suppress(ProcessLookupError):
158+
os.kill(pid, signal.SIGKILL)
159+
signal_name = 'SIGKILL'
160+
161+
data['stopped_at'] = _utc_now()
162+
data['stop_signal'] = signal_name
163+
data['alive'] = False
164+
if not self.readonly:
165+
_persist_record_state(self._record_path(background_id), data)
166+
return data
167+
168+
124169

125170
def _persist_record_state(path: Path, data: dict[str, Any]) -> None:
126171
persisted = {k: v for k, v in data.items() if k != 'alive'}
@@ -141,7 +186,12 @@ def _refresh_process_state(data: dict[str, Any], record_path: Path, *, persist:
141186
return data
142187

143188
pid = int(data['pid'])
144-
alive = _is_alive(pid)
189+
try:
190+
os.kill(pid, 0)
191+
alive = True
192+
except OSError:
193+
alive = False
194+
145195
exit_code: Optional[int] = None
146196
if alive:
147197
try:
@@ -165,7 +215,21 @@ def _refresh_process_state(data: dict[str, Any], record_path: Path, *, persist:
165215
return data
166216

167217

218+
def _reap(pid: int) -> bool:
219+
try:
220+
finished_pid, _ = os.waitpid(pid, os.WNOHANG)
221+
except ChildProcessError:
222+
return False
223+
except OSError:
224+
return False
225+
return finished_pid == pid
226+
227+
168228
def _is_alive(pid: int) -> bool:
229+
if pid <= 0:
230+
return False
231+
if _reap(pid):
232+
return False
169233
try:
170234
os.kill(pid, 0)
171235
except OSError:

0 commit comments

Comments
 (0)