jupyter-ai-acp-client 0.0.1__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.
Files changed (26) hide show
  1. jupyter_ai_acp_client/__init__.py +25 -0
  2. jupyter_ai_acp_client/_version.py +4 -0
  3. jupyter_ai_acp_client/acp_personas/claude.py +30 -0
  4. jupyter_ai_acp_client/acp_personas/test.py +26 -0
  5. jupyter_ai_acp_client/base_acp_persona.py +163 -0
  6. jupyter_ai_acp_client/default_acp_client.py +368 -0
  7. jupyter_ai_acp_client/extension_app.py +29 -0
  8. jupyter_ai_acp_client/routes.py +79 -0
  9. jupyter_ai_acp_client/static/claude.svg +7 -0
  10. jupyter_ai_acp_client/static/test.svg +40 -0
  11. jupyter_ai_acp_client/terminal_manager.py +334 -0
  12. jupyter_ai_acp_client/tests/__init__.py +1 -0
  13. jupyter_ai_acp_client/tests/test_routes.py +13 -0
  14. jupyter_ai_acp_client-0.0.1.data/data/etc/jupyter/jupyter_server_config.d/jupyter_ai_acp_client.json +7 -0
  15. jupyter_ai_acp_client-0.0.1.data/data/share/jupyter/labextensions/@jupyter-ai/acp-client/install.json +5 -0
  16. jupyter_ai_acp_client-0.0.1.data/data/share/jupyter/labextensions/@jupyter-ai/acp-client/package.json +222 -0
  17. jupyter_ai_acp_client-0.0.1.data/data/share/jupyter/labextensions/@jupyter-ai/acp-client/static/728.f69b40505cc5a8669c1e.js +1 -0
  18. jupyter_ai_acp_client-0.0.1.data/data/share/jupyter/labextensions/@jupyter-ai/acp-client/static/750.a29148656dc2b5c07d11.js +1 -0
  19. jupyter_ai_acp_client-0.0.1.data/data/share/jupyter/labextensions/@jupyter-ai/acp-client/static/remoteEntry.e615ae2e4254ce11d925.js +1 -0
  20. jupyter_ai_acp_client-0.0.1.data/data/share/jupyter/labextensions/@jupyter-ai/acp-client/static/style.js +4 -0
  21. jupyter_ai_acp_client-0.0.1.data/data/share/jupyter/labextensions/@jupyter-ai/acp-client/static/third-party-licenses.json +16 -0
  22. jupyter_ai_acp_client-0.0.1.dist-info/METADATA +304 -0
  23. jupyter_ai_acp_client-0.0.1.dist-info/RECORD +26 -0
  24. jupyter_ai_acp_client-0.0.1.dist-info/WHEEL +4 -0
  25. jupyter_ai_acp_client-0.0.1.dist-info/entry_points.txt +3 -0
  26. jupyter_ai_acp_client-0.0.1.dist-info/licenses/LICENSE +29 -0
@@ -0,0 +1,25 @@
1
+ try:
2
+ from ._version import __version__
3
+ except ImportError:
4
+ # Fallback when using the package in dev mode without installing
5
+ # in editable mode with pip. It is highly recommended to install
6
+ # the package from a stable release or in editable mode: https://pip.pypa.io/en/stable/topics/local-project-installs/#editable-installs
7
+ import warnings
8
+ warnings.warn("Importing 'jupyter_ai_acp_client' outside a proper installation.")
9
+ __version__ = "dev"
10
+
11
+ from .extension_app import JaiAcpClientExtension
12
+
13
+
14
+ def _jupyter_labextension_paths():
15
+ return [{
16
+ "src": "labextension",
17
+ "dest": "@jupyter-ai/acp-client"
18
+ }]
19
+
20
+
21
+ def _jupyter_server_extension_points():
22
+ return [{
23
+ "module": "jupyter_ai_acp_client",
24
+ "app": JaiAcpClientExtension
25
+ }]
@@ -0,0 +1,4 @@
1
+ # This file is auto-generated by Hatchling. As such, do not:
2
+ # - modify
3
+ # - track in version control e.g. be sure to add to .gitignore
4
+ __version__ = VERSION = '0.0.1'
@@ -0,0 +1,30 @@
1
+ import shutil
2
+ from jupyter_ai_persona_manager import PersonaRequirementsUnmet
3
+ if shutil.which("claude-code-acp") is None:
4
+ raise PersonaRequirementsUnmet(
5
+ "This persona requires the Claude Code ACP adapter to be installed."
6
+ " Install it via `npm install -g @zed-industries/claude-code-acp`"
7
+ " then restart."
8
+ )
9
+
10
+ import os
11
+ from ..base_acp_persona import BaseAcpPersona
12
+ from jupyter_ai_persona_manager import PersonaDefaults
13
+
14
+ class ClaudeAcpPersona(BaseAcpPersona):
15
+ def __init__(self, *args, **kwargs):
16
+ executable = ["claude-code-acp"]
17
+ super().__init__(*args, executable=executable, **kwargs)
18
+
19
+ @property
20
+ def defaults(self) -> PersonaDefaults:
21
+ avatar_path = str(os.path.abspath(
22
+ os.path.join(os.path.dirname(__file__), "..", "static", "claude.svg")
23
+ ))
24
+
25
+ return PersonaDefaults(
26
+ name="Claude-ACP",
27
+ description="Claude Code as an ACP agent persona.",
28
+ avatar_path=avatar_path,
29
+ system_prompt="unused"
30
+ )
@@ -0,0 +1,26 @@
1
+ import os
2
+ import sys
3
+ from ..base_acp_persona import BaseAcpPersona
4
+ from jupyter_ai_persona_manager import PersonaDefaults
5
+
6
+ class TestAcpPersona(BaseAcpPersona):
7
+ def __init__(self, *args, **kwargs):
8
+ # Get absolute path to agent.py
9
+ agent_path = os.path.abspath(
10
+ os.path.join(os.path.dirname(__file__), "..", "..", "examples", "agent.py")
11
+ )
12
+ executable = [sys.executable, agent_path]
13
+ super().__init__(*args, executable=executable, **kwargs)
14
+
15
+ @property
16
+ def defaults(self) -> PersonaDefaults:
17
+ avatar_path = str(os.path.abspath(
18
+ os.path.join(os.path.dirname(__file__), "..", "static", "test.svg")
19
+ ))
20
+
21
+ return PersonaDefaults(
22
+ name="Test-ACP",
23
+ description="A test ACP persona",
24
+ avatar_path=avatar_path,
25
+ system_prompt="unused"
26
+ )
@@ -0,0 +1,163 @@
1
+ from jupyter_ai_persona_manager import BasePersona
2
+ from jupyterlab_chat.models import Message
3
+ import asyncio
4
+ import sys
5
+ from asyncio.subprocess import Process
6
+ from typing import Awaitable, ClassVar
7
+ from acp import NewSessionResponse
8
+ from acp.schema import AvailableCommand
9
+
10
+ from .default_acp_client import JaiAcpClient
11
+
12
+
13
+ class BaseAcpPersona(BasePersona):
14
+ _subprocess_future: ClassVar[Awaitable[Process] | None] = None
15
+ """
16
+ The task that yields the agent subprocess once complete. This is a class
17
+ attribute because multiple instances of the same ACP persona may share an
18
+ ACP agent subprocess.
19
+
20
+ Developers should always use `self.get_agent_subprocess()`.
21
+ """
22
+
23
+ _client_future: ClassVar[Awaitable[JaiAcpClient] | None] = None
24
+ """
25
+ The future that yields the ACP Client once complete. This is a class
26
+ attribute because multiple instances of the same ACP persona may share an
27
+ ACP client as well. ACP agent subprocesses and clients map 1-to-1.
28
+
29
+ Developers should always use `self.get_client()`.
30
+ """
31
+
32
+ _client_session_future: Awaitable[NewSessionResponse]
33
+ """
34
+ The future that yields the ACP client session info. Each instance of an ACP
35
+ persona has a unique session ID, i.e. each chat reserves a unique session.
36
+
37
+ Developers should always call `self.get_session()` or `self.get_session_id()`.
38
+ """
39
+
40
+ _acp_slash_commands: list[AvailableCommand]
41
+
42
+ def __init__(self, *args, executable: list[str], **kwargs):
43
+ super().__init__(*args, **kwargs)
44
+
45
+ self._executable = executable
46
+
47
+ # Ensure each subclass has its own subprocess and client by checking if the
48
+ # class variable is defined directly on this class (not inherited)
49
+ if '_subprocess_future' not in self.__class__.__dict__ or self.__class__._subprocess_future is None:
50
+ self.__class__._subprocess_future = self.event_loop.create_task(
51
+ self._init_agent_subprocess()
52
+ )
53
+ if '_client_future' not in self.__class__.__dict__ or self.__class__._client_future is None:
54
+ self.__class__._client_future = self.event_loop.create_task(
55
+ self._init_client()
56
+ )
57
+
58
+ self._client_session_future = self.event_loop.create_task(
59
+ self._init_client_session()
60
+ )
61
+ self._acp_slash_commands = []
62
+
63
+ async def _init_agent_subprocess(self) -> Process:
64
+ process = await asyncio.create_subprocess_exec(
65
+ *self._executable,
66
+ stdin=asyncio.subprocess.PIPE,
67
+ stdout=asyncio.subprocess.PIPE,
68
+ stderr=sys.stderr,
69
+ )
70
+ self.log.info(f"Spawned ACP agent subprocess for '{self.__class__.__name__}'.")
71
+ return process
72
+
73
+ async def _init_client(self) -> JaiAcpClient:
74
+ agent_subprocess = await self.get_agent_subprocess()
75
+ client = JaiAcpClient(agent_subprocess=agent_subprocess, event_loop=self.event_loop)
76
+ self.log.info(f"Initialized ACP client for '{self.__class__.__name__}'.")
77
+ return client
78
+
79
+ async def _init_client_session(self) -> NewSessionResponse:
80
+ client = await self.get_client()
81
+ session = await client.create_session(persona=self)
82
+ self.log.info(
83
+ f"Initialized new ACP client session for '{self.__class__.__name__}'"
84
+ f" with ID '{session.session_id}'."
85
+ )
86
+ return session
87
+
88
+ async def get_agent_subprocess(self) -> asyncio.subprocess.Process:
89
+ """
90
+ Safely returns the ACP agent subprocess for this persona.
91
+ """
92
+ return await self.__class__._subprocess_future
93
+
94
+ async def get_client(self) -> JaiAcpClient:
95
+ """
96
+ Safely returns the ACP client for this persona.
97
+ """
98
+ return await self.__class__._client_future
99
+
100
+ async def get_session(self) -> NewSessionResponse:
101
+ """
102
+ Safely returns the ACP client session for this chat.
103
+ """
104
+ return await self._client_session_future
105
+
106
+ async def get_session_id(self) -> str:
107
+ """
108
+ Safely returns the ACP client ID assigned to this chat.
109
+ """
110
+ session = await self._client_session_future
111
+ return session.session_id
112
+
113
+ async def process_message(self, message: Message) -> None:
114
+ """
115
+ A default implementation for the `BasePersona.process_message()` method
116
+ for ACP agents.
117
+
118
+ This method may be overriden by child classes.
119
+ """
120
+ client = await self.get_client()
121
+ session_id = await self.get_session_id()
122
+
123
+ # TODO: add attachments!
124
+ prompt = message.body.replace("@" + self.as_user().mention_name, "").strip()
125
+ await client.prompt_and_reply(
126
+ session_id=session_id,
127
+ prompt=prompt,
128
+ )
129
+
130
+ @property
131
+ def acp_slash_commands(self) -> list[AvailableCommand]:
132
+ """
133
+ Returns the list of slash commands advertised by the ACP agent in the
134
+ current session.
135
+
136
+ This initializes to an empty list, and should be updated **only** by the
137
+ ACP client upon receiving a `session/update` request containing an
138
+ `AvailableCommandsUpdate` payload from the ACP agent.
139
+ """
140
+ return self._acp_slash_commands
141
+
142
+ @acp_slash_commands.setter
143
+ def acp_slash_commands(self, commands: list[AvailableCommand]):
144
+ self.log.info(
145
+ f"Setting {len(commands)} slash commands for '{self.name}' in room '{self.parent.room_id}'."
146
+ )
147
+ self._acp_slash_commands = commands
148
+
149
+ def shutdown(self):
150
+ # TODO: allow shutdown() to be async
151
+ self.event_loop.create_task(self._shutdown())
152
+
153
+ async def _shutdown(self):
154
+ self.log.info(f"Closing ACP agent and client for '{self.__class__.__name__}'.")
155
+ client = await self.get_client()
156
+ conn = await client.get_connection()
157
+ await conn.close()
158
+ subprocess = await self.get_agent_subprocess()
159
+ try:
160
+ subprocess.kill()
161
+ except ProcessLookupError:
162
+ pass
163
+ self.log.info(f"Completed closed ACP agent and client for '{self.__class__.__name__}'.")
@@ -0,0 +1,368 @@
1
+ import asyncio
2
+ import contextlib
3
+ import logging
4
+ import os
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Any, AsyncIterator
8
+
9
+ from acp import (
10
+ PROTOCOL_VERSION,
11
+ Client,
12
+ RequestError,
13
+ connect_to_agent,
14
+ text_block,
15
+ )
16
+ from acp.core import ClientSideConnection
17
+ from acp.schema import (
18
+ AgentMessageChunk,
19
+ AgentPlanUpdate,
20
+ AgentThoughtChunk,
21
+ AudioContentBlock,
22
+ AvailableCommandsUpdate,
23
+ ClientCapabilities,
24
+ CreateTerminalResponse,
25
+ CurrentModeUpdate,
26
+ EmbeddedResourceContentBlock,
27
+ EnvVariable,
28
+ FileSystemCapability,
29
+ ImageContentBlock,
30
+ Implementation,
31
+ KillTerminalCommandResponse,
32
+ NewSessionResponse,
33
+ PermissionOption,
34
+ PromptResponse,
35
+ ReadTextFileResponse,
36
+ ReleaseTerminalResponse,
37
+ RequestPermissionResponse,
38
+ ResourceContentBlock,
39
+ TerminalOutputResponse,
40
+ TextContentBlock,
41
+ ToolCall,
42
+ ToolCallProgress,
43
+ ToolCallStart,
44
+ UserMessageChunk,
45
+ WaitForTerminalExitResponse,
46
+ WriteTextFileResponse,
47
+ AllowedOutcome
48
+ )
49
+ from jupyter_ai_persona_manager import BasePersona
50
+ from typing import Awaitable
51
+ from asyncio.subprocess import Process
52
+
53
+ from .terminal_manager import TerminalManager
54
+
55
+ async def queue_to_iterator(queue: asyncio.Queue[str], sentinel: str = "__end__") -> AsyncIterator[str]:
56
+ """Convert an asyncio queue to an async iterator."""
57
+ while True:
58
+ item = await queue.get()
59
+ if item == sentinel:
60
+ break
61
+ yield item
62
+
63
+ class JaiAcpClient(Client):
64
+ """
65
+ The default ACP client. The client should be stored as a class attribute on each
66
+ ACP persona, such that each ACP agent subprocess is communicated through
67
+ exactly one ACP client (an instance of this class).
68
+ """
69
+
70
+ agent_subprocess: Process
71
+ _connection_future: Awaitable[ClientSideConnection]
72
+ event_loop: asyncio.AbstractEventLoop
73
+ _personas_by_session: dict[str, BasePersona]
74
+ _queues_by_session: dict[str, asyncio.Queue[str]]
75
+ _terminal_manager: TerminalManager
76
+
77
+ def __init__(self, *args, agent_subprocess: Awaitable[Process], event_loop: asyncio.AbstractEventLoop, **kwargs):
78
+ """
79
+ :param agent_subprocess: The ACP agent subprocess
80
+ (`asyncio.subprocess.Process`) assigned to this client.
81
+
82
+ :param event_loop: The `asyncio` event loop running this process.
83
+ """
84
+ self.agent_subprocess = agent_subprocess
85
+ # Each client instance needs its own connection to its own subprocess
86
+ self._connection_future = event_loop.create_task(
87
+ self._init_connection()
88
+ )
89
+ self.event_loop = event_loop
90
+ # Each client instance maintains its own session mappings
91
+ self._personas_by_session = {}
92
+ self._queues_by_session = {}
93
+ self._terminal_manager = TerminalManager(event_loop)
94
+ super().__init__(*args, **kwargs)
95
+
96
+
97
+ async def _init_connection(self) -> ClientSideConnection:
98
+ proc = self.agent_subprocess
99
+ conn = connect_to_agent(self, proc.stdin, proc.stdout)
100
+ await conn.initialize(
101
+ protocol_version=PROTOCOL_VERSION,
102
+ client_capabilities=ClientCapabilities(
103
+ fs=FileSystemCapability(read_text_file=True, write_text_file=True),
104
+ terminal=True,
105
+ ),
106
+ client_info=Implementation(name="Jupyter AI", title="Jupyter AI ACP Client", version="0.1.0"),
107
+ )
108
+ return conn
109
+
110
+ async def get_connection(self) -> ClientSideConnection:
111
+ return await self._connection_future
112
+
113
+ async def create_session(self, persona: BasePersona) -> NewSessionResponse:
114
+ """
115
+ Create an ACP agent session through this client scoped to a
116
+ `BasePersona` instance.
117
+ """
118
+ conn = await self.get_connection()
119
+ # TODO: change this to Jupyter preferred dir
120
+ session = await conn.new_session(mcp_servers=[], cwd=os.getcwd())
121
+ self._personas_by_session[session.session_id] = persona
122
+ return session
123
+
124
+ async def prompt_and_reply(self, session_id: str, prompt: str, attachments: list[dict] = []) -> PromptResponse:
125
+ """
126
+ A helper method that sends a prompt with an optional list of attachments
127
+ to the assigned ACP server. This method writes back to the chat by
128
+ calling methods on the persona corresponding to this session ID.
129
+ """
130
+ assert session_id in self._personas_by_session
131
+ conn = await self.get_connection()
132
+
133
+ # ensure an asyncio Queue exists for this session
134
+ # the `session_update()` method will push chunks to this queue
135
+ queue = self._queues_by_session.get(session_id, None)
136
+ if queue is None:
137
+ queue: asyncio.Queue[str] = asyncio.Queue()
138
+ self._queues_by_session[session_id] = queue
139
+
140
+ # create async iterator that yields until the response is complete
141
+ aiter = queue_to_iterator(queue)
142
+
143
+ # create background task to stream message back to client using the
144
+ # dedicated persona method
145
+ persona = self._personas_by_session[session_id]
146
+ self.event_loop.create_task(
147
+ persona.stream_message(aiter)
148
+ )
149
+
150
+ # call the model and await
151
+ # TODO: add attachments!
152
+ response = await conn.prompt(
153
+ prompt=[
154
+ TextContentBlock(text=prompt, type="text"),
155
+ ],
156
+ session_id=session_id
157
+ )
158
+
159
+ # push sentinel value to queue to close the async iterator
160
+ queue.put_nowait("__end__")
161
+
162
+ return response
163
+
164
+ async def session_update(
165
+ self,
166
+ session_id: str,
167
+ update: UserMessageChunk
168
+ | AgentMessageChunk
169
+ | AgentThoughtChunk
170
+ | ToolCallStart
171
+ | ToolCallProgress
172
+ | AgentPlanUpdate
173
+ | AvailableCommandsUpdate
174
+ | CurrentModeUpdate,
175
+ **kwargs: Any,
176
+ ) -> None:
177
+ """
178
+ Handles `session/update` requests from the ACP agent. There must be an
179
+ `asyncio.Queue` corresponding to this session ID - this should be set by
180
+ the `prompt_and_reply()` method.
181
+ """
182
+
183
+ if isinstance(update, AvailableCommandsUpdate):
184
+ if not update.available_commands:
185
+ return
186
+ persona = self._personas_by_session.get(session_id)
187
+ if persona and hasattr(persona, 'acp_slash_commands'):
188
+ persona.acp_slash_commands = update.available_commands
189
+ return
190
+
191
+ if not isinstance(update, AgentMessageChunk):
192
+ return
193
+
194
+ if session_id not in self._queues_by_session:
195
+ logging.error(f"No queue found for session_id: {session_id}")
196
+ return
197
+
198
+ content = update.content
199
+ text: str
200
+ if isinstance(content, TextContentBlock):
201
+ text = content.text
202
+ elif isinstance(content, ImageContentBlock):
203
+ text = "<image>"
204
+ elif isinstance(content, AudioContentBlock):
205
+ text = "<audio>"
206
+ elif isinstance(content, ResourceContentBlock):
207
+ text = content.uri or "<resource>"
208
+ elif isinstance(content, EmbeddedResourceContentBlock):
209
+ text = "<resource>"
210
+ else:
211
+ text = "<content>"
212
+
213
+ queue = self._queues_by_session[session_id]
214
+ queue.put_nowait(text)
215
+
216
+ async def request_permission(
217
+ self, options: list[PermissionOption], session_id: str, tool_call: ToolCall, **kwargs: Any
218
+ ) -> RequestPermissionResponse:
219
+ """
220
+ Handles `session/request_permission` requests from the ACP agent.
221
+
222
+ TODO: This currently always gives the agent permission. We will need to
223
+ add some tool call approval UI and handle permission requests properly.
224
+ """
225
+ option_id = ""
226
+ for o in options:
227
+ if "allow" in o.option_id.lower():
228
+ option_id = o.option_id
229
+ break
230
+
231
+ return RequestPermissionResponse(
232
+ outcome=AllowedOutcome(option_id=option_id, outcome='selected')
233
+ )
234
+
235
+ async def write_text_file(
236
+ self, content: str, path: str, session_id: str, **kwargs: Any
237
+ ) -> WriteTextFileResponse | None:
238
+ # Validate path parameter
239
+ if not path or not path.strip():
240
+ raise RequestError.invalid_params({"path": "path cannot be empty"})
241
+
242
+ file_path = Path(path)
243
+
244
+ # Check if path is a directory
245
+ if file_path.is_dir():
246
+ raise RequestError.invalid_params({"path": "path cannot be a directory"})
247
+
248
+ try:
249
+ file_path.parent.mkdir(parents=True, exist_ok=True)
250
+ await asyncio.to_thread(file_path.write_text, content, encoding="utf-8")
251
+ except PermissionError as e:
252
+ raise RequestError.internal_error({"path": path, "error": f"Permission denied: {e}"})
253
+ except OSError as e:
254
+ raise RequestError.internal_error({"path": path, "error": str(e)})
255
+
256
+ return WriteTextFileResponse()
257
+
258
+ async def read_text_file(
259
+ self, path: str, session_id: str, limit: int | None = None, line: int | None = None, **kwargs: Any
260
+ ) -> ReadTextFileResponse:
261
+ # Validate path parameter
262
+ if not path or not path.strip():
263
+ raise RequestError.invalid_params({"path": "path cannot be empty"})
264
+
265
+ # Validate line parameter (must be >= 1 if provided)
266
+ if line is not None and line < 1:
267
+ raise RequestError.invalid_params({"line": "line must be >= 1 (1-indexed)"})
268
+
269
+ # Validate limit parameter (must be >= 1 if provided)
270
+ if limit is not None and limit < 1:
271
+ raise RequestError.invalid_params({"limit": "limit must be >= 1"})
272
+
273
+ file_path = Path(path)
274
+
275
+ # Check if file exists
276
+ if not file_path.exists():
277
+ raise RequestError.resource_not_found(path)
278
+
279
+ # Check if path is a directory
280
+ if file_path.is_dir():
281
+ raise RequestError.invalid_params({"path": "path cannot be a directory"})
282
+
283
+ try:
284
+ text = await asyncio.to_thread(file_path.read_text, encoding="utf-8")
285
+ except PermissionError as e:
286
+ raise RequestError.internal_error({"path": path, "error": f"Permission denied: {e}"})
287
+ except OSError as e:
288
+ raise RequestError.internal_error({"path": path, "error": str(e)})
289
+
290
+ lines = text.splitlines(keepends=True)
291
+
292
+ # line is 1-indexed; default to line 1 if not specified
293
+ start_index = (line - 1) if line is not None else 0
294
+
295
+ if limit is not None:
296
+ lines = lines[start_index : start_index + limit]
297
+ else:
298
+ lines = lines[start_index:]
299
+
300
+ content = "".join(lines)
301
+ return ReadTextFileResponse(content=content)
302
+
303
+ ##############################
304
+ # Terminal methods
305
+ ##############################
306
+
307
+ async def create_terminal(
308
+ self,
309
+ command: str,
310
+ session_id: str,
311
+ args: list[str] | None = None,
312
+ cwd: str | None = None,
313
+ env: list[EnvVariable] | None = None,
314
+ output_byte_limit: int | None = None,
315
+ **kwargs: Any,
316
+ ) -> CreateTerminalResponse:
317
+ return await self._terminal_manager.create_terminal(
318
+ command=command,
319
+ session_id=session_id,
320
+ args=args,
321
+ cwd=cwd,
322
+ env=env,
323
+ output_byte_limit=output_byte_limit,
324
+ **kwargs,
325
+ )
326
+
327
+ async def terminal_output(
328
+ self, session_id: str, terminal_id: str, **kwargs: Any
329
+ ) -> TerminalOutputResponse:
330
+ return await self._terminal_manager.terminal_output(
331
+ session_id=session_id,
332
+ terminal_id=terminal_id,
333
+ **kwargs,
334
+ )
335
+
336
+ async def release_terminal(
337
+ self, session_id: str, terminal_id: str, **kwargs: Any
338
+ ) -> ReleaseTerminalResponse | None:
339
+ return await self._terminal_manager.release_terminal(
340
+ session_id=session_id,
341
+ terminal_id=terminal_id,
342
+ **kwargs,
343
+ )
344
+
345
+ async def wait_for_terminal_exit(
346
+ self, session_id: str, terminal_id: str, **kwargs: Any
347
+ ) -> WaitForTerminalExitResponse:
348
+ return await self._terminal_manager.wait_for_terminal_exit(
349
+ session_id=session_id,
350
+ terminal_id=terminal_id,
351
+ **kwargs,
352
+ )
353
+
354
+ async def kill_terminal(
355
+ self, session_id: str, terminal_id: str, **kwargs: Any
356
+ ) -> KillTerminalCommandResponse | None:
357
+ return await self._terminal_manager.kill_terminal(
358
+ session_id=session_id,
359
+ terminal_id=terminal_id,
360
+ **kwargs,
361
+ )
362
+
363
+ async def ext_method(self, method: str, params: dict) -> dict:
364
+ raise RequestError.method_not_found(method)
365
+
366
+ async def ext_notification(self, method: str, params: dict) -> None:
367
+ raise RequestError.method_not_found(method)
368
+
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+ from typing import TYPE_CHECKING
3
+ from jupyter_server.extension.application import ExtensionApp
4
+ from .routes import AcpSlashCommandsHandler
5
+
6
+
7
+ class JaiAcpClientExtension(ExtensionApp):
8
+ """
9
+ Jupyter AI ACP client extension.
10
+ """
11
+
12
+ name = "jupyter_ai_acp_client"
13
+ handlers = [
14
+ (r"ai/acp/slash_commands/?([^/]*)?", AcpSlashCommandsHandler),
15
+ ]
16
+
17
+ def initialize_settings(self):
18
+ """Initialize router settings and event listeners."""
19
+ # # Ensure 'jupyter-ai' dictionary is in `self.settings`, which gets
20
+ # # copied to `self.serverapp.web_app.settings` after this method returns
21
+ # if 'jupyter-ai' not in self.settings:
22
+ # self.settings['jupyter-ai'] = {}
23
+
24
+ # self.settings['jupyter-ai']['acp-client']
25
+ return
26
+
27
+ async def stop_extension(self):
28
+ """Clean up router when extension stops."""
29
+ return