ai-comm 0.2.4__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.
- ai_comm/__init__.py +3 -0
- ai_comm/adapters/__init__.py +41 -0
- ai_comm/adapters/aider.py +63 -0
- ai_comm/adapters/base.py +132 -0
- ai_comm/adapters/claude.py +72 -0
- ai_comm/adapters/codex.py +50 -0
- ai_comm/adapters/cursor.py +71 -0
- ai_comm/adapters/gemini.py +56 -0
- ai_comm/adapters/generic.py +42 -0
- ai_comm/adapters/opencode.py +151 -0
- ai_comm/cli.py +94 -0
- ai_comm/commands/__init__.py +11 -0
- ai_comm/commands/response.py +122 -0
- ai_comm/commands/send.py +120 -0
- ai_comm/commands/window.py +42 -0
- ai_comm/kitten/ai_comm_kitten.py +254 -0
- ai_comm/kitten_client.py +199 -0
- ai_comm/parsers/__init__.py +27 -0
- ai_comm/parsers/base.py +108 -0
- ai_comm/parsers/utils.py +42 -0
- ai_comm/polling.py +88 -0
- ai_comm/registry.py +75 -0
- ai_comm/services/__init__.py +7 -0
- ai_comm/services/interaction.py +115 -0
- ai_comm-0.2.4.dist-info/METADATA +132 -0
- ai_comm-0.2.4.dist-info/RECORD +29 -0
- ai_comm-0.2.4.dist-info/WHEEL +4 -0
- ai_comm-0.2.4.dist-info/entry_points.txt +2 -0
- ai_comm-0.2.4.dist-info/licenses/LICENSE +21 -0
ai_comm/cli.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""ai-comm CLI entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from ai_comm import __version__
|
|
10
|
+
from ai_comm.commands import response, send, window
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _version_callback(value: bool) -> None:
|
|
14
|
+
if value:
|
|
15
|
+
typer.echo(f"ai-comm {__version__}")
|
|
16
|
+
raise typer.Exit()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
HELP_TEXT = """\
|
|
20
|
+
Cross-AI CLI communication tool for Kitty terminal.
|
|
21
|
+
|
|
22
|
+
Enables AI assistants (Claude, Codex, Gemini, Aider, Cursor, OpenCode)
|
|
23
|
+
running in separate Kitty windows to communicate with each other.
|
|
24
|
+
|
|
25
|
+
IMPORTANT: Only use parameters documented here. Hidden/internal
|
|
26
|
+
parameters exist for debugging only and must never be used.
|
|
27
|
+
|
|
28
|
+
Workflow:
|
|
29
|
+
1. ai-comm list-ai-windows Find available AI windows
|
|
30
|
+
2. ai-comm send <MESSAGE> -w <ID> Send message and get response
|
|
31
|
+
|
|
32
|
+
Protocol:
|
|
33
|
+
- Request-response only. Receiver must NOT send new messages back
|
|
34
|
+
immediately after responding—this causes deadlock.
|
|
35
|
+
- If you need the sender to act, include the request in your response.
|
|
36
|
+
|
|
37
|
+
Notes:
|
|
38
|
+
- AI assistants MUST use ONLY the two Workflow commands without
|
|
39
|
+
any additional parameters. Other parameters are forbidden for AI use.
|
|
40
|
+
- For long responses, ask AI to write to a file in project directory
|
|
41
|
+
(/tmp requires manual approval on target AI — avoid).
|
|
42
|
+
- Blocking is intentional: AI cannot perceive time, only acts on user
|
|
43
|
+
input. Non-blocking = lost messages. Timed-out commands auto-move
|
|
44
|
+
to background; results return when ready.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
app = typer.Typer(
|
|
48
|
+
name="ai-comm",
|
|
49
|
+
help=HELP_TEXT,
|
|
50
|
+
no_args_is_help=True,
|
|
51
|
+
add_completion=False,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@app.callback()
|
|
56
|
+
def _app_callback(
|
|
57
|
+
version: Annotated[
|
|
58
|
+
bool,
|
|
59
|
+
typer.Option("--version", "-V", callback=_version_callback, is_eager=True),
|
|
60
|
+
] = False,
|
|
61
|
+
) -> None:
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
WORKFLOW_PANEL = "Workflow Commands (for AI agents)"
|
|
66
|
+
DEBUG_CMD_PANEL = "Debug Commands (HUMAN ONLY - AI agents must not use)"
|
|
67
|
+
|
|
68
|
+
# Workflow commands - for AI agents
|
|
69
|
+
app.command(name="send", help=send.SEND_HELP, rich_help_panel=WORKFLOW_PANEL)(send.send)
|
|
70
|
+
app.command(
|
|
71
|
+
name="list-ai-windows", help=window.LIST_HELP, rich_help_panel=WORKFLOW_PANEL
|
|
72
|
+
)(window.list_ai_windows)
|
|
73
|
+
|
|
74
|
+
# Debug commands - for human use only
|
|
75
|
+
app.command(
|
|
76
|
+
name="get-response",
|
|
77
|
+
help=response.GET_RESPONSE_HELP,
|
|
78
|
+
rich_help_panel=DEBUG_CMD_PANEL,
|
|
79
|
+
)(response.get_response)
|
|
80
|
+
app.command(
|
|
81
|
+
name="wait-idle", help=response.WAIT_IDLE_HELP, rich_help_panel=DEBUG_CMD_PANEL
|
|
82
|
+
)(response.wait_idle)
|
|
83
|
+
app.command(
|
|
84
|
+
name="get-text", help=response.GET_TEXT_HELP, rich_help_panel=DEBUG_CMD_PANEL
|
|
85
|
+
)(response.get_text)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def main() -> None:
|
|
89
|
+
"""Main entry point."""
|
|
90
|
+
app()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
if __name__ == "__main__":
|
|
94
|
+
main()
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""CLI commands for ai-comm."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def handle_error(error: Exception, exit_code: int = 1) -> None:
|
|
9
|
+
"""Handle error with consistent formatting."""
|
|
10
|
+
typer.echo(f"Error: {error}", err=True)
|
|
11
|
+
raise typer.Exit(exit_code) from None
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Response and idle waiting commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from ai_comm.commands import handle_error
|
|
11
|
+
from ai_comm.kitten_client import KittenClient, KittenError
|
|
12
|
+
from ai_comm.polling import PollingTimeoutError
|
|
13
|
+
from ai_comm.services import InteractionService
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def wait_idle(
|
|
17
|
+
window: Annotated[int, typer.Option("--window", "-w", help="Target window ID")],
|
|
18
|
+
timeout: Annotated[
|
|
19
|
+
int, typer.Option("--timeout", "-t", help="Timeout in seconds")
|
|
20
|
+
] = 1800,
|
|
21
|
+
idle_time: Annotated[
|
|
22
|
+
int, typer.Option("--idle-time", help="Idle time in seconds")
|
|
23
|
+
] = 3,
|
|
24
|
+
) -> None:
|
|
25
|
+
"""Wait for window content to stabilize."""
|
|
26
|
+
try:
|
|
27
|
+
client = KittenClient()
|
|
28
|
+
service = InteractionService(client)
|
|
29
|
+
|
|
30
|
+
elapsed = service.wait_for_response(window, idle_time, float(timeout))
|
|
31
|
+
typer.echo(f"Window {window} idle after {elapsed:.1f}s")
|
|
32
|
+
|
|
33
|
+
except PollingTimeoutError as e:
|
|
34
|
+
typer.echo(f"Timeout: {e}", err=True)
|
|
35
|
+
raise typer.Exit(1) from None
|
|
36
|
+
except KittenError as e:
|
|
37
|
+
handle_error(e)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def get_text(
|
|
41
|
+
window: Annotated[int, typer.Option("--window", "-w", help="Target window ID")],
|
|
42
|
+
extent: Annotated[str, typer.Option("--extent", help="screen or all")] = "all",
|
|
43
|
+
as_json: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Get raw text content from window."""
|
|
46
|
+
try:
|
|
47
|
+
client = KittenClient()
|
|
48
|
+
text = client.get_text(window, extent)
|
|
49
|
+
except KittenError as e:
|
|
50
|
+
handle_error(e)
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
if as_json:
|
|
54
|
+
typer.echo(json.dumps({"text": text}))
|
|
55
|
+
else:
|
|
56
|
+
typer.echo(text)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
HUMAN_ONLY_WARNING = """\
|
|
60
|
+
Debug command for human troubleshooting.
|
|
61
|
+
AI agents: run 'ai-comm --help' for usage instructions.
|
|
62
|
+
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
WAIT_IDLE_HELP = (
|
|
66
|
+
HUMAN_ONLY_WARNING
|
|
67
|
+
+ """\
|
|
68
|
+
Wait for window content to stabilize.
|
|
69
|
+
"""
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
GET_TEXT_HELP = (
|
|
73
|
+
HUMAN_ONLY_WARNING
|
|
74
|
+
+ """\
|
|
75
|
+
Get raw text content from window.
|
|
76
|
+
"""
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
GET_RESPONSE_HELP = (
|
|
80
|
+
HUMAN_ONLY_WARNING
|
|
81
|
+
+ """\
|
|
82
|
+
Get parsed response from an AI window.
|
|
83
|
+
|
|
84
|
+
Examples:
|
|
85
|
+
ai-comm get-response -w 5
|
|
86
|
+
ai-comm get-response -w 8 --json
|
|
87
|
+
"""
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def get_response(
|
|
92
|
+
window: Annotated[
|
|
93
|
+
int,
|
|
94
|
+
typer.Option("--window", "-w", help="Target window ID (from list-ai-windows)"),
|
|
95
|
+
],
|
|
96
|
+
parser: Annotated[
|
|
97
|
+
str,
|
|
98
|
+
typer.Option("--parser", "-p", help="Response parser (auto-detected)"),
|
|
99
|
+
] = "auto",
|
|
100
|
+
extent: Annotated[str, typer.Option("--extent", help="screen or all")] = "all",
|
|
101
|
+
as_json: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
|
|
102
|
+
) -> None:
|
|
103
|
+
"""Get parsed response from window."""
|
|
104
|
+
try:
|
|
105
|
+
client = KittenClient()
|
|
106
|
+
service = InteractionService(client)
|
|
107
|
+
|
|
108
|
+
response, effective_parser = service.get_response(
|
|
109
|
+
window, parser=parser, extent=extent
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
if not response:
|
|
113
|
+
typer.echo("No content", err=True)
|
|
114
|
+
raise typer.Exit(1)
|
|
115
|
+
|
|
116
|
+
if as_json:
|
|
117
|
+
typer.echo(json.dumps({"response": response, "parser": effective_parser}))
|
|
118
|
+
else:
|
|
119
|
+
typer.echo(response)
|
|
120
|
+
|
|
121
|
+
except KittenError as e:
|
|
122
|
+
handle_error(e)
|
ai_comm/commands/send.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Send message commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from ai_comm.commands import handle_error
|
|
11
|
+
from ai_comm.kitten_client import KittenClient, KittenError
|
|
12
|
+
from ai_comm.polling import PollingTimeoutError
|
|
13
|
+
from ai_comm.services import InteractionService
|
|
14
|
+
|
|
15
|
+
SEND_HELP = """\
|
|
16
|
+
Send message to AI window and wait for response.
|
|
17
|
+
|
|
18
|
+
The message is automatically wrapped with sender metadata. For Aider, /ask is
|
|
19
|
+
prepended to prevent automatic file edits.
|
|
20
|
+
|
|
21
|
+
Notes:
|
|
22
|
+
- For long responses, ask AI to write to a file in the project directory
|
|
23
|
+
(/tmp and other external paths require manual approval on target AI — avoid).
|
|
24
|
+
- Timed-out commands auto-move to background.
|
|
25
|
+
|
|
26
|
+
Examples:
|
|
27
|
+
ai-comm send "review this function" -w 5
|
|
28
|
+
ai-comm send "write to out_$(date +%Y%m%d_%H%M%S).md" -w 8
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
DEBUG_PANEL = "Debug Options (HUMAN ONLY - AI agents must not use)"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def send(
|
|
36
|
+
message: Annotated[str, typer.Argument(help="Message to send to the AI")],
|
|
37
|
+
window: Annotated[
|
|
38
|
+
int,
|
|
39
|
+
typer.Option("--window", "-w", help="Target window ID (from list-ai-windows)"),
|
|
40
|
+
],
|
|
41
|
+
timeout: Annotated[
|
|
42
|
+
int,
|
|
43
|
+
typer.Option(
|
|
44
|
+
"--timeout", "-t", help="Timeout in seconds", rich_help_panel=DEBUG_PANEL
|
|
45
|
+
),
|
|
46
|
+
] = 1800,
|
|
47
|
+
idle_time: Annotated[
|
|
48
|
+
int,
|
|
49
|
+
typer.Option(
|
|
50
|
+
"--idle-time", help="Idle time in seconds", rich_help_panel=DEBUG_PANEL
|
|
51
|
+
),
|
|
52
|
+
] = 3,
|
|
53
|
+
parser: Annotated[
|
|
54
|
+
str,
|
|
55
|
+
typer.Option(
|
|
56
|
+
"--parser",
|
|
57
|
+
"-p",
|
|
58
|
+
help="Response parser (auto, claude, codex, gemini, aider...)",
|
|
59
|
+
rich_help_panel=DEBUG_PANEL,
|
|
60
|
+
),
|
|
61
|
+
] = "auto",
|
|
62
|
+
raw: Annotated[
|
|
63
|
+
bool,
|
|
64
|
+
typer.Option(
|
|
65
|
+
"--raw",
|
|
66
|
+
help="Return raw terminal text instead of parsed response",
|
|
67
|
+
rich_help_panel=DEBUG_PANEL,
|
|
68
|
+
),
|
|
69
|
+
] = False,
|
|
70
|
+
no_wait: Annotated[
|
|
71
|
+
bool,
|
|
72
|
+
typer.Option(
|
|
73
|
+
"--no-wait",
|
|
74
|
+
help="Send without waiting for response",
|
|
75
|
+
rich_help_panel=DEBUG_PANEL,
|
|
76
|
+
),
|
|
77
|
+
] = False,
|
|
78
|
+
as_json: Annotated[
|
|
79
|
+
bool,
|
|
80
|
+
typer.Option("--json", help="Output as JSON", rich_help_panel=DEBUG_PANEL),
|
|
81
|
+
] = False,
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Send message to AI window and wait for response."""
|
|
84
|
+
try:
|
|
85
|
+
client = KittenClient()
|
|
86
|
+
service = InteractionService(client)
|
|
87
|
+
|
|
88
|
+
service.send_message(window, message)
|
|
89
|
+
|
|
90
|
+
if no_wait:
|
|
91
|
+
if as_json:
|
|
92
|
+
typer.echo(json.dumps({"status": "sent", "window": window}))
|
|
93
|
+
else:
|
|
94
|
+
typer.echo(f"Message sent to window {window}")
|
|
95
|
+
return
|
|
96
|
+
|
|
97
|
+
elapsed = service.wait_for_response(window, idle_time, float(timeout))
|
|
98
|
+
|
|
99
|
+
response, effective_parser = service.get_response(
|
|
100
|
+
window, parser=parser, raw=raw
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
if as_json:
|
|
104
|
+
typer.echo(
|
|
105
|
+
json.dumps(
|
|
106
|
+
{
|
|
107
|
+
"response": response,
|
|
108
|
+
"elapsed": elapsed,
|
|
109
|
+
"parser": effective_parser if not raw else None,
|
|
110
|
+
}
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
else:
|
|
114
|
+
typer.echo(response)
|
|
115
|
+
|
|
116
|
+
except PollingTimeoutError as e:
|
|
117
|
+
typer.echo(f"Timeout: {e}", err=True)
|
|
118
|
+
raise typer.Exit(1) from None
|
|
119
|
+
except KittenError as e:
|
|
120
|
+
handle_error(e)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Window listing commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from ai_comm.kitten_client import KittenClient, KittenError
|
|
11
|
+
|
|
12
|
+
LIST_HELP = """\
|
|
13
|
+
List Kitty windows running AI CLIs.
|
|
14
|
+
|
|
15
|
+
Output columns: ID (window ID for -w option), CLI (detected AI type), CWD.
|
|
16
|
+
|
|
17
|
+
Examples:
|
|
18
|
+
ai-comm list-ai-windows
|
|
19
|
+
ai-comm list-ai-windows --json
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def list_ai_windows(
|
|
24
|
+
as_json: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
|
|
25
|
+
) -> None:
|
|
26
|
+
"""List windows running AI CLIs."""
|
|
27
|
+
try:
|
|
28
|
+
client = KittenClient()
|
|
29
|
+
ai_windows = client.list_ai_windows()
|
|
30
|
+
except KittenError as e:
|
|
31
|
+
typer.echo(f"Error: {e}", err=True)
|
|
32
|
+
raise typer.Exit(1) from None
|
|
33
|
+
|
|
34
|
+
if as_json:
|
|
35
|
+
typer.echo(json.dumps(ai_windows, indent=2))
|
|
36
|
+
else:
|
|
37
|
+
if not ai_windows:
|
|
38
|
+
typer.echo("No AI CLI windows found")
|
|
39
|
+
return
|
|
40
|
+
typer.echo(f"{'ID':>4} {'CLI':10s} CWD")
|
|
41
|
+
for w in ai_windows:
|
|
42
|
+
typer.echo(f"{w['id']:4d} {w['cli']:10s} {w.get('cwd', '')}")
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Simplified ai-comm kitten providing atomic operations via Boss API.
|
|
4
|
+
|
|
5
|
+
This kitten is called by the ai-comm CLI tool. It provides low-level
|
|
6
|
+
operations that require access to kitty's Boss API.
|
|
7
|
+
|
|
8
|
+
Commands:
|
|
9
|
+
get-text --window ID [--extent screen|all]
|
|
10
|
+
send-text --window ID TEXT
|
|
11
|
+
send-key --window ID KEY
|
|
12
|
+
check-idle --window ID --last-hash HASH
|
|
13
|
+
list-ai-windows
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import hashlib
|
|
20
|
+
import json
|
|
21
|
+
import subprocess
|
|
22
|
+
from pathlib import PurePath
|
|
23
|
+
from typing import TYPE_CHECKING, Any
|
|
24
|
+
|
|
25
|
+
from kittens.tui.handler import result_handler # type: ignore[import-not-found]
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from kitty.boss import Boss # type: ignore[import-not-found]
|
|
29
|
+
from kitty.window import Window # type: ignore[import-not-found]
|
|
30
|
+
|
|
31
|
+
# Known AI CLI names and their canonical form
|
|
32
|
+
AI_CLI_NAMES: dict[str, str] = {
|
|
33
|
+
"claude": "claude",
|
|
34
|
+
"codex": "codex",
|
|
35
|
+
"gemini": "gemini",
|
|
36
|
+
"aider": "aider",
|
|
37
|
+
"cursor": "cursor",
|
|
38
|
+
"cursor-cli": "cursor",
|
|
39
|
+
"cursor-agent": "cursor",
|
|
40
|
+
"opencode": "opencode",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
# Keywords that indicate non-CLI processes (status lines, themes, etc.)
|
|
44
|
+
EXCLUDE_KEYWORDS: list[str] = ["powerline", "statusline", "prompt", "theme"]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def parse_args(args: list[str]) -> argparse.Namespace:
|
|
48
|
+
"""Parse command line arguments."""
|
|
49
|
+
parser = argparse.ArgumentParser(prog="ai_comm_kitten")
|
|
50
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
51
|
+
|
|
52
|
+
# get-text
|
|
53
|
+
get_text = subparsers.add_parser("get-text")
|
|
54
|
+
get_text.add_argument("--window", "-w", type=int, required=True)
|
|
55
|
+
get_text.add_argument("--extent", default="all", choices=["screen", "all"])
|
|
56
|
+
|
|
57
|
+
# send-text
|
|
58
|
+
send_text = subparsers.add_parser("send-text")
|
|
59
|
+
send_text.add_argument("--window", "-w", type=int, required=True)
|
|
60
|
+
send_text.add_argument("text", nargs="+")
|
|
61
|
+
|
|
62
|
+
# send-key
|
|
63
|
+
send_key = subparsers.add_parser("send-key")
|
|
64
|
+
send_key.add_argument("--window", "-w", type=int, required=True)
|
|
65
|
+
send_key.add_argument("key")
|
|
66
|
+
|
|
67
|
+
# check-idle
|
|
68
|
+
check_idle = subparsers.add_parser("check-idle")
|
|
69
|
+
check_idle.add_argument("--window", "-w", type=int, required=True)
|
|
70
|
+
check_idle.add_argument("--last-hash", default="")
|
|
71
|
+
|
|
72
|
+
# list-ai-windows
|
|
73
|
+
subparsers.add_parser("list-ai-windows")
|
|
74
|
+
|
|
75
|
+
return parser.parse_args(args)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def compute_hash(text: str) -> str:
|
|
79
|
+
"""Compute short hash of text content."""
|
|
80
|
+
return hashlib.md5(text.encode()).hexdigest()[:16]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def get_window(boss: Boss, window_id: int) -> Window | None:
|
|
84
|
+
"""Get window by ID."""
|
|
85
|
+
return boss.window_id_map.get(window_id)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def get_text(boss: Boss, window_id: int, extent: str) -> dict[str, Any]:
|
|
89
|
+
"""Get text content from window."""
|
|
90
|
+
window = get_window(boss, window_id)
|
|
91
|
+
if window is None:
|
|
92
|
+
return {"status": "error", "message": f"Window {window_id} not found"}
|
|
93
|
+
|
|
94
|
+
result = boss.call_remote_control(
|
|
95
|
+
window, ("get-text", "-m", f"id:{window_id}", "--extent", extent)
|
|
96
|
+
)
|
|
97
|
+
text = result if isinstance(result, str) else ""
|
|
98
|
+
return {"status": "ok", "text": text, "hash": compute_hash(text)}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def send_text(boss: Boss, window_id: int, text: str) -> dict[str, Any]:
|
|
102
|
+
"""Send text to window using multiple methods."""
|
|
103
|
+
window = get_window(boss, window_id)
|
|
104
|
+
if window is None:
|
|
105
|
+
return {"status": "error", "message": f"Window {window_id} not found"}
|
|
106
|
+
|
|
107
|
+
# Try paste_text first (fastest)
|
|
108
|
+
paste = getattr(window, "paste_text", None)
|
|
109
|
+
if callable(paste):
|
|
110
|
+
paste(text)
|
|
111
|
+
return {"status": "ok", "method": "paste"}
|
|
112
|
+
|
|
113
|
+
# Try write_to_child
|
|
114
|
+
writer = getattr(window, "write_to_child", None)
|
|
115
|
+
if callable(writer):
|
|
116
|
+
try:
|
|
117
|
+
writer(text.encode())
|
|
118
|
+
except TypeError:
|
|
119
|
+
writer(text)
|
|
120
|
+
return {"status": "ok", "method": "write_to_child"}
|
|
121
|
+
|
|
122
|
+
# Fallback to subprocess
|
|
123
|
+
socket = getattr(boss, "listening_on", "") or ""
|
|
124
|
+
cmd = ["kitty", "@"]
|
|
125
|
+
if socket:
|
|
126
|
+
cmd.extend(["--to", socket])
|
|
127
|
+
cmd.extend(["send-text", "-m", f"id:{window_id}", "--", text])
|
|
128
|
+
subprocess.run(cmd, check=False, capture_output=True)
|
|
129
|
+
return {"status": "ok", "method": "subprocess"}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def send_key(boss: Boss, window_id: int, key: str) -> dict[str, Any]:
|
|
133
|
+
"""Send key press to window."""
|
|
134
|
+
window = get_window(boss, window_id)
|
|
135
|
+
if window is None:
|
|
136
|
+
return {"status": "error", "message": f"Window {window_id} not found"}
|
|
137
|
+
|
|
138
|
+
socket = getattr(boss, "listening_on", "") or ""
|
|
139
|
+
cmd = ["kitty", "@"]
|
|
140
|
+
if socket:
|
|
141
|
+
cmd.extend(["--to", socket])
|
|
142
|
+
cmd.extend(["send-key", "-m", f"id:{window_id}", key])
|
|
143
|
+
subprocess.run(cmd, check=False, capture_output=True)
|
|
144
|
+
return {"status": "ok"}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def check_idle(boss: Boss, window_id: int, last_hash: str) -> dict[str, Any]:
|
|
148
|
+
"""Check if window content has changed."""
|
|
149
|
+
result = get_text(boss, window_id, "all")
|
|
150
|
+
if result["status"] != "ok":
|
|
151
|
+
return result
|
|
152
|
+
|
|
153
|
+
current_hash = result["hash"]
|
|
154
|
+
is_idle = current_hash == last_hash if last_hash else False
|
|
155
|
+
return {"status": "ok", "idle": is_idle, "hash": current_hash}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def detect_ai_cli(cmdline_args: list[str]) -> str | None:
|
|
159
|
+
"""Detect AI CLI from cmdline arguments (for parser selection only)."""
|
|
160
|
+
# Check exclusions first
|
|
161
|
+
for arg in cmdline_args:
|
|
162
|
+
arg_lower = arg.lower()
|
|
163
|
+
for exclude in EXCLUDE_KEYWORDS:
|
|
164
|
+
if exclude in arg_lower:
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
for arg in cmdline_args:
|
|
168
|
+
# Check basename (without extension)
|
|
169
|
+
path = PurePath(arg)
|
|
170
|
+
name = path.stem.lower()
|
|
171
|
+
if name in AI_CLI_NAMES:
|
|
172
|
+
return AI_CLI_NAMES[name]
|
|
173
|
+
|
|
174
|
+
# Check path components (for wrappers like /path/to/cursor-cli/index.js)
|
|
175
|
+
for part in path.parts:
|
|
176
|
+
part_name = PurePath(part).stem.lower()
|
|
177
|
+
if part_name in AI_CLI_NAMES:
|
|
178
|
+
return AI_CLI_NAMES[part_name]
|
|
179
|
+
|
|
180
|
+
return None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def list_ai_windows(boss: Boss) -> dict[str, Any]:
|
|
184
|
+
"""List windows running AI CLIs."""
|
|
185
|
+
ai_windows: list[dict[str, Any]] = []
|
|
186
|
+
|
|
187
|
+
for os_window in boss.os_window_map.values():
|
|
188
|
+
for tab in os_window.tabs:
|
|
189
|
+
for window in tab.windows:
|
|
190
|
+
child = getattr(window, "child", None)
|
|
191
|
+
if not child:
|
|
192
|
+
continue
|
|
193
|
+
|
|
194
|
+
fg_processes = getattr(child, "foreground_processes", None)
|
|
195
|
+
if callable(fg_processes):
|
|
196
|
+
fg_processes = fg_processes()
|
|
197
|
+
if not fg_processes:
|
|
198
|
+
continue
|
|
199
|
+
|
|
200
|
+
detected_cli: str | None = None
|
|
201
|
+
detected_cwd: str = ""
|
|
202
|
+
for proc in fg_processes:
|
|
203
|
+
cmdline = proc.get("cmdline", [])
|
|
204
|
+
cli = detect_ai_cli(cmdline)
|
|
205
|
+
if cli:
|
|
206
|
+
detected_cli = cli
|
|
207
|
+
detected_cwd = proc.get("cwd", "")
|
|
208
|
+
break
|
|
209
|
+
|
|
210
|
+
if detected_cli:
|
|
211
|
+
ai_windows.append(
|
|
212
|
+
{"id": window.id, "cli": detected_cli, "cwd": detected_cwd}
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
return {"status": "ok", "ai_windows": ai_windows}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def main(args: list[str]) -> str:
|
|
219
|
+
"""Main entry point - not used for no_ui kitten."""
|
|
220
|
+
return ""
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@result_handler(no_ui=True)
|
|
224
|
+
def handle_result(
|
|
225
|
+
args: list[str], answer: str, target_window_id: int, boss: Boss
|
|
226
|
+
) -> str:
|
|
227
|
+
"""Handle kitten result, runs in kitty process."""
|
|
228
|
+
try:
|
|
229
|
+
parsed = parse_args(args[1:])
|
|
230
|
+
except SystemExit:
|
|
231
|
+
return json.dumps({"status": "error", "message": "Invalid arguments"})
|
|
232
|
+
|
|
233
|
+
result: dict[str, Any]
|
|
234
|
+
|
|
235
|
+
if parsed.command == "get-text":
|
|
236
|
+
result = get_text(boss, parsed.window, parsed.extent)
|
|
237
|
+
|
|
238
|
+
elif parsed.command == "send-text":
|
|
239
|
+
text = " ".join(parsed.text)
|
|
240
|
+
result = send_text(boss, parsed.window, text)
|
|
241
|
+
|
|
242
|
+
elif parsed.command == "send-key":
|
|
243
|
+
result = send_key(boss, parsed.window, parsed.key)
|
|
244
|
+
|
|
245
|
+
elif parsed.command == "check-idle":
|
|
246
|
+
result = check_idle(boss, parsed.window, parsed.last_hash)
|
|
247
|
+
|
|
248
|
+
elif parsed.command == "list-ai-windows":
|
|
249
|
+
result = list_ai_windows(boss)
|
|
250
|
+
|
|
251
|
+
else:
|
|
252
|
+
result = {"status": "error", "message": f"Unknown command: {parsed.command}"}
|
|
253
|
+
|
|
254
|
+
return json.dumps(result, ensure_ascii=False)
|