opencomputer 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.
Files changed (51) hide show
  1. opencomputer/__init__.py +3 -0
  2. opencomputer/agent/__init__.py +1 -0
  3. opencomputer/agent/compaction.py +245 -0
  4. opencomputer/agent/config.py +108 -0
  5. opencomputer/agent/config_store.py +210 -0
  6. opencomputer/agent/injection.py +60 -0
  7. opencomputer/agent/loop.py +326 -0
  8. opencomputer/agent/memory.py +132 -0
  9. opencomputer/agent/prompt_builder.py +66 -0
  10. opencomputer/agent/prompts/base.j2 +23 -0
  11. opencomputer/agent/state.py +251 -0
  12. opencomputer/agent/step.py +31 -0
  13. opencomputer/cli.py +483 -0
  14. opencomputer/doctor.py +216 -0
  15. opencomputer/gateway/__init__.py +1 -0
  16. opencomputer/gateway/dispatch.py +89 -0
  17. opencomputer/gateway/protocol.py +84 -0
  18. opencomputer/gateway/server.py +77 -0
  19. opencomputer/gateway/wire_server.py +256 -0
  20. opencomputer/hooks/__init__.py +1 -0
  21. opencomputer/hooks/engine.py +79 -0
  22. opencomputer/hooks/runner.py +42 -0
  23. opencomputer/mcp/__init__.py +1 -0
  24. opencomputer/mcp/client.py +208 -0
  25. opencomputer/plugins/__init__.py +1 -0
  26. opencomputer/plugins/discovery.py +107 -0
  27. opencomputer/plugins/loader.py +155 -0
  28. opencomputer/plugins/registry.py +56 -0
  29. opencomputer/setup_wizard.py +235 -0
  30. opencomputer/skills/debug-python-import-error/SKILL.md +58 -0
  31. opencomputer/tools/__init__.py +1 -0
  32. opencomputer/tools/bash.py +78 -0
  33. opencomputer/tools/delegate.py +98 -0
  34. opencomputer/tools/glob.py +70 -0
  35. opencomputer/tools/grep.py +117 -0
  36. opencomputer/tools/read.py +81 -0
  37. opencomputer/tools/registry.py +69 -0
  38. opencomputer/tools/skill_manage.py +265 -0
  39. opencomputer/tools/write.py +58 -0
  40. opencomputer-0.1.0.dist-info/METADATA +190 -0
  41. opencomputer-0.1.0.dist-info/RECORD +51 -0
  42. opencomputer-0.1.0.dist-info/WHEEL +4 -0
  43. opencomputer-0.1.0.dist-info/entry_points.txt +3 -0
  44. plugin_sdk/__init__.py +66 -0
  45. plugin_sdk/channel_contract.py +74 -0
  46. plugin_sdk/core.py +129 -0
  47. plugin_sdk/hooks.py +80 -0
  48. plugin_sdk/injection.py +60 -0
  49. plugin_sdk/provider_contract.py +95 -0
  50. plugin_sdk/runtime_context.py +39 -0
  51. plugin_sdk/tool_contract.py +67 -0
@@ -0,0 +1,89 @@
1
+ """
2
+ Gateway dispatch — route inbound MessageEvents to the agent loop.
3
+
4
+ This is the glue between channel adapters (Telegram, Discord, etc.)
5
+ and the AgentLoop. Each adapter calls `Dispatch.handle_message(event)`;
6
+ we map chat_id → session_id and invoke the loop.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import hashlib
13
+ import logging
14
+
15
+ from opencomputer.agent.loop import AgentLoop
16
+ from plugin_sdk.core import MessageEvent
17
+
18
+ logger = logging.getLogger("opencomputer.gateway.dispatch")
19
+
20
+
21
+ class Dispatch:
22
+ """Map channel messages to agent-loop runs, keeping per-chat sessions separate."""
23
+
24
+ def __init__(self, loop: AgentLoop) -> None:
25
+ self.loop = loop
26
+ # One lock per chat_id — prevents interleaved turns from the same chat
27
+ self._locks: dict[str, asyncio.Lock] = {}
28
+ # Adapter reference (set by Gateway) so we can send typing indicators
29
+ self._adapters_by_platform: dict = {}
30
+
31
+ def register_adapter(self, platform: str, adapter) -> None:
32
+ self._adapters_by_platform[platform] = adapter
33
+
34
+ def _session_id_for(self, event: MessageEvent) -> str:
35
+ """Stable session id: hash(platform + chat_id). Keeps history per chat."""
36
+ h = hashlib.sha256(f"{event.platform.value}:{event.chat_id}".encode())
37
+ return h.hexdigest()[:32]
38
+
39
+ async def handle_message(self, event: MessageEvent) -> str | None:
40
+ """
41
+ Handle one inbound message. Runs the agent loop and returns the
42
+ final assistant text for the adapter to send back.
43
+
44
+ Also starts a periodic typing-indicator heartbeat on the source
45
+ channel so the user sees "..." while the agent thinks.
46
+ """
47
+ if not event.text.strip():
48
+ return None
49
+ session_id = self._session_id_for(event)
50
+ lock = self._locks.setdefault(session_id, asyncio.Lock())
51
+ async with lock:
52
+ # Start a typing heartbeat (Telegram's typing state expires after
53
+ # ~5s, so we re-send every 4s until the turn completes).
54
+ heartbeat = asyncio.create_task(
55
+ self._typing_heartbeat(event.platform.value, event.chat_id)
56
+ )
57
+ try:
58
+ result = await self.loop.run_conversation(
59
+ user_message=event.text,
60
+ session_id=session_id,
61
+ )
62
+ return result.final_message.content or None
63
+ except Exception as e: # noqa: BLE001
64
+ logger.exception("dispatch error for %s: %s", event.platform, e)
65
+ return f"[error: {type(e).__name__}: {e}]"
66
+ finally:
67
+ heartbeat.cancel()
68
+ try:
69
+ await heartbeat
70
+ except (asyncio.CancelledError, Exception):
71
+ pass
72
+
73
+ async def _typing_heartbeat(self, platform: str, chat_id: str) -> None:
74
+ """Send typing indicator every 4s until cancelled."""
75
+ adapter = self._adapters_by_platform.get(platform)
76
+ if adapter is None:
77
+ return
78
+ try:
79
+ while True:
80
+ try:
81
+ await adapter.send_typing(chat_id)
82
+ except Exception:
83
+ pass # typing is best-effort
84
+ await asyncio.sleep(4.0)
85
+ except asyncio.CancelledError:
86
+ return
87
+
88
+
89
+ __all__ = ["Dispatch"]
@@ -0,0 +1,84 @@
1
+ """
2
+ Gateway wire protocol — JSON over WebSocket.
3
+
4
+ Clients connect to the gateway and exchange messages in this format.
5
+ Openclaw-style: typed schemas, request/response/event messages.
6
+
7
+ Three message shapes:
8
+ req — client → gateway request {type:"req", id, method, params}
9
+ res — gateway → client response {type:"res", id, ok, payload?, error?}
10
+ event — gateway → client server-push event {type:"event", event, payload}
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Any, Literal
16
+
17
+ from pydantic import BaseModel, Field
18
+
19
+ # ─── Request ────────────────────────────────────────────────────
20
+
21
+
22
+ class WireRequest(BaseModel):
23
+ type: Literal["req"] = "req"
24
+ id: str
25
+ method: str
26
+ params: dict[str, Any] = Field(default_factory=dict)
27
+
28
+
29
+ # ─── Response ───────────────────────────────────────────────────
30
+
31
+
32
+ class WireResponse(BaseModel):
33
+ type: Literal["res"] = "res"
34
+ id: str
35
+ ok: bool
36
+ payload: dict[str, Any] | None = None
37
+ error: str | None = None
38
+
39
+
40
+ # ─── Events (server-push) ───────────────────────────────────────
41
+
42
+
43
+ class WireEvent(BaseModel):
44
+ type: Literal["event"] = "event"
45
+ event: str # e.g. "turn.begin", "tool.call", "assistant.delta", "turn.end"
46
+ payload: dict[str, Any] = Field(default_factory=dict)
47
+
48
+
49
+ # ─── Method names (client → gateway) ────────────────────────────
50
+
51
+ # These are the RPC methods clients can call.
52
+ METHOD_HELLO = "hello" # handshake, exchange capabilities
53
+ METHOD_CHAT = "chat" # send a user message, get assistant response
54
+ METHOD_SESSION_LIST = "sessions.list"
55
+ METHOD_SEARCH = "search"
56
+ METHOD_SKILLS_LIST = "skills.list"
57
+
58
+
59
+ # ─── Event names (gateway → client) ─────────────────────────────
60
+
61
+ EVENT_TURN_BEGIN = "turn.begin"
62
+ EVENT_TURN_END = "turn.end"
63
+ EVENT_TOOL_CALL = "tool.call"
64
+ EVENT_TOOL_RESULT = "tool.result"
65
+ EVENT_ASSISTANT_MESSAGE = "assistant.message"
66
+ EVENT_ERROR = "error"
67
+
68
+
69
+ __all__ = [
70
+ "WireRequest",
71
+ "WireResponse",
72
+ "WireEvent",
73
+ "METHOD_HELLO",
74
+ "METHOD_CHAT",
75
+ "METHOD_SESSION_LIST",
76
+ "METHOD_SEARCH",
77
+ "METHOD_SKILLS_LIST",
78
+ "EVENT_TURN_BEGIN",
79
+ "EVENT_TURN_END",
80
+ "EVENT_TOOL_CALL",
81
+ "EVENT_TOOL_RESULT",
82
+ "EVENT_ASSISTANT_MESSAGE",
83
+ "EVENT_ERROR",
84
+ ]
@@ -0,0 +1,77 @@
1
+ """
2
+ Gateway daemon — runs channel adapters + optional WebSocket server.
3
+
4
+ Two modes:
5
+ 1. Channel mode: start configured channel adapters (Telegram, Discord, ...).
6
+ Messages arrive via platform SDKs → Dispatch → AgentLoop → back out.
7
+ 2. Wire mode (optional): also start a WebSocket server on a local port,
8
+ letting additional clients (TUI, web, mobile) use the same agent via
9
+ the typed protocol.
10
+
11
+ Phase 2 focuses on channel mode. Wire mode is scaffolded but minimal.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import logging
18
+
19
+ from opencomputer.agent.loop import AgentLoop
20
+ from opencomputer.gateway.dispatch import Dispatch
21
+ from plugin_sdk.channel_contract import BaseChannelAdapter
22
+
23
+ logger = logging.getLogger("opencomputer.gateway.server")
24
+
25
+
26
+ class Gateway:
27
+ """The gateway daemon."""
28
+
29
+ def __init__(self, loop: AgentLoop) -> None:
30
+ self.loop = loop
31
+ self.dispatch = Dispatch(loop)
32
+ self._adapters: list[BaseChannelAdapter] = []
33
+
34
+ def register_adapter(self, adapter: BaseChannelAdapter) -> None:
35
+ """Register a channel adapter (usually from a loaded plugin)."""
36
+ adapter.set_message_handler(self.dispatch.handle_message)
37
+ self._adapters.append(adapter)
38
+ # Give Dispatch a handle so it can send typing indicators back out.
39
+ self.dispatch.register_adapter(adapter.platform.value, adapter)
40
+
41
+ async def start(self) -> None:
42
+ """Connect all adapters. Returns once they're all running."""
43
+ logger.info("gateway: starting %d adapters", len(self._adapters))
44
+ results = await asyncio.gather(
45
+ *(a.connect() for a in self._adapters), return_exceptions=True
46
+ )
47
+ for adapter, res in zip(self._adapters, results, strict=False):
48
+ if isinstance(res, Exception):
49
+ logger.error(
50
+ "gateway: adapter %s failed to connect: %s", adapter.platform, res
51
+ )
52
+ elif res is False:
53
+ logger.error("gateway: adapter %s returned False from connect()", adapter.platform)
54
+
55
+ async def stop(self) -> None:
56
+ logger.info("gateway: stopping")
57
+ await asyncio.gather(
58
+ *(a.disconnect() for a in self._adapters), return_exceptions=True
59
+ )
60
+
61
+ async def serve_forever(self) -> None:
62
+ """Connect adapters and block until interrupted."""
63
+ await self.start()
64
+ try:
65
+ while True:
66
+ await asyncio.sleep(3600)
67
+ except asyncio.CancelledError:
68
+ pass
69
+ finally:
70
+ await self.stop()
71
+
72
+ @property
73
+ def adapters(self) -> list[BaseChannelAdapter]:
74
+ return list(self._adapters)
75
+
76
+
77
+ __all__ = ["Gateway"]
@@ -0,0 +1,256 @@
1
+ """
2
+ Wire server — WebSocket JSON-RPC server for TUI / IDE / web clients.
3
+
4
+ Listens on 127.0.0.1:<port> by default. Each connection is independent —
5
+ clients send WireRequests, receive WireResponses (and server-pushed
6
+ WireEvents during long-running calls like chat).
7
+
8
+ Supported methods:
9
+ hello — handshake, returns server capabilities
10
+ chat — send a user message, stream assistant response
11
+ sessions.list — list recent sessions
12
+ search — FTS5 search across session history
13
+ skills.list — list available skills
14
+
15
+ New methods can be added by plugins in a future phase.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import json
22
+ import logging
23
+ import uuid
24
+ from typing import Any
25
+
26
+ import websockets
27
+
28
+ from opencomputer.agent.loop import AgentLoop
29
+ from opencomputer.gateway.protocol import (
30
+ EVENT_ASSISTANT_MESSAGE,
31
+ EVENT_ERROR,
32
+ EVENT_TURN_BEGIN,
33
+ EVENT_TURN_END,
34
+ METHOD_CHAT,
35
+ METHOD_HELLO,
36
+ METHOD_SEARCH,
37
+ METHOD_SESSION_LIST,
38
+ METHOD_SKILLS_LIST,
39
+ WireEvent,
40
+ WireRequest,
41
+ WireResponse,
42
+ )
43
+
44
+ logger = logging.getLogger("opencomputer.gateway.wire_server")
45
+
46
+
47
+ class WireServer:
48
+ """Minimal JSON-RPC-over-WebSocket server for local clients."""
49
+
50
+ def __init__(
51
+ self,
52
+ loop: AgentLoop,
53
+ host: str = "127.0.0.1",
54
+ port: int = 18789,
55
+ ) -> None:
56
+ self.loop = loop
57
+ self.host = host
58
+ self.port = port
59
+ self._server: websockets.WebSocketServer | None = None
60
+
61
+ async def start(self) -> None:
62
+ self._server = await websockets.serve(
63
+ self._handle_client, self.host, self.port
64
+ )
65
+ logger.info("wire: listening on ws://%s:%s", self.host, self.port)
66
+
67
+ async def stop(self) -> None:
68
+ if self._server is not None:
69
+ self._server.close()
70
+ await self._server.wait_closed()
71
+
72
+ async def _handle_client(
73
+ self, ws: websockets.WebSocketServerProtocol
74
+ ) -> None:
75
+ client_id = str(uuid.uuid4())[:8]
76
+ logger.info("wire: client %s connected", client_id)
77
+ try:
78
+ async for raw in ws:
79
+ try:
80
+ data = json.loads(raw)
81
+ except json.JSONDecodeError:
82
+ await self._send_response(
83
+ ws, "", False, error="invalid JSON"
84
+ )
85
+ continue
86
+
87
+ if data.get("type") != "req":
88
+ await self._send_response(
89
+ ws, data.get("id", ""), False, error="expected type=req"
90
+ )
91
+ continue
92
+
93
+ try:
94
+ req = WireRequest(**data)
95
+ except Exception as e:
96
+ await self._send_response(
97
+ ws,
98
+ data.get("id", ""),
99
+ False,
100
+ error=f"invalid request: {e}",
101
+ )
102
+ continue
103
+
104
+ try:
105
+ await self._dispatch(ws, req)
106
+ except Exception as e: # noqa: BLE001
107
+ logger.exception("wire dispatch error for method %s", req.method)
108
+ await self._send_response(
109
+ ws, req.id, False, error=f"{type(e).__name__}: {e}"
110
+ )
111
+ except websockets.ConnectionClosed:
112
+ pass
113
+ finally:
114
+ logger.info("wire: client %s disconnected", client_id)
115
+
116
+ async def _dispatch(
117
+ self, ws: websockets.WebSocketServerProtocol, req: WireRequest
118
+ ) -> None:
119
+ if req.method == METHOD_HELLO:
120
+ await self._send_response(
121
+ ws,
122
+ req.id,
123
+ True,
124
+ payload={
125
+ "server": "opencomputer",
126
+ "version": "0.0.1",
127
+ "methods": [
128
+ METHOD_HELLO,
129
+ METHOD_CHAT,
130
+ METHOD_SESSION_LIST,
131
+ METHOD_SEARCH,
132
+ METHOD_SKILLS_LIST,
133
+ ],
134
+ "events": [
135
+ EVENT_TURN_BEGIN,
136
+ EVENT_TURN_END,
137
+ EVENT_ASSISTANT_MESSAGE,
138
+ EVENT_ERROR,
139
+ ],
140
+ },
141
+ )
142
+ elif req.method == METHOD_CHAT:
143
+ await self._handle_chat(ws, req)
144
+ elif req.method == METHOD_SESSION_LIST:
145
+ limit = int(req.params.get("limit", 20))
146
+ rows = self.loop.db.list_sessions(limit=limit)
147
+ await self._send_response(ws, req.id, True, payload={"sessions": rows})
148
+ elif req.method == METHOD_SEARCH:
149
+ query = str(req.params.get("query", ""))
150
+ limit = int(req.params.get("limit", 20))
151
+ hits = self.loop.db.search(query, limit=limit)
152
+ await self._send_response(ws, req.id, True, payload={"hits": hits})
153
+ elif req.method == METHOD_SKILLS_LIST:
154
+ skills = self.loop.memory.list_skills()
155
+ payload = {
156
+ "skills": [
157
+ {
158
+ "id": s.id,
159
+ "name": s.name,
160
+ "description": s.description,
161
+ "version": s.version,
162
+ }
163
+ for s in skills
164
+ ]
165
+ }
166
+ await self._send_response(ws, req.id, True, payload=payload)
167
+ else:
168
+ await self._send_response(
169
+ ws, req.id, False, error=f"unknown method: {req.method}"
170
+ )
171
+
172
+ async def _handle_chat(
173
+ self, ws: websockets.WebSocketServerProtocol, req: WireRequest
174
+ ) -> None:
175
+ user_message = str(req.params.get("message", "")).strip()
176
+ session_id = req.params.get("session_id") or None
177
+ if not user_message:
178
+ await self._send_response(
179
+ ws, req.id, False, error="empty message"
180
+ )
181
+ return
182
+
183
+ # Announce turn begin
184
+ await self._send_event(
185
+ ws, EVENT_TURN_BEGIN, {"request_id": req.id}
186
+ )
187
+
188
+ # Stream text deltas to the client as assistant messages
189
+ async def _on_chunk(text: str) -> None:
190
+ await self._send_event(
191
+ ws,
192
+ EVENT_ASSISTANT_MESSAGE,
193
+ {"delta": text, "request_id": req.id},
194
+ )
195
+
196
+ try:
197
+ result = await self.loop.run_conversation(
198
+ user_message=user_message,
199
+ session_id=session_id,
200
+ stream_callback=lambda t: asyncio.create_task(_on_chunk(t)),
201
+ )
202
+ except Exception as e: # noqa: BLE001
203
+ await self._send_event(
204
+ ws,
205
+ EVENT_ERROR,
206
+ {"request_id": req.id, "error": f"{type(e).__name__}: {e}"},
207
+ )
208
+ await self._send_response(
209
+ ws, req.id, False, error=f"{type(e).__name__}: {e}"
210
+ )
211
+ return
212
+
213
+ await self._send_event(
214
+ ws,
215
+ EVENT_TURN_END,
216
+ {
217
+ "request_id": req.id,
218
+ "iterations": result.iterations,
219
+ "input_tokens": result.input_tokens,
220
+ "output_tokens": result.output_tokens,
221
+ "session_id": result.session_id,
222
+ },
223
+ )
224
+ await self._send_response(
225
+ ws,
226
+ req.id,
227
+ True,
228
+ payload={
229
+ "text": result.final_message.content,
230
+ "session_id": result.session_id,
231
+ "iterations": result.iterations,
232
+ },
233
+ )
234
+
235
+ async def _send_response(
236
+ self,
237
+ ws: websockets.WebSocketServerProtocol,
238
+ req_id: str,
239
+ ok: bool,
240
+ payload: dict[str, Any] | None = None,
241
+ error: str | None = None,
242
+ ) -> None:
243
+ res = WireResponse(id=req_id, ok=ok, payload=payload, error=error)
244
+ await ws.send(res.model_dump_json())
245
+
246
+ async def _send_event(
247
+ self,
248
+ ws: websockets.WebSocketServerProtocol,
249
+ event_name: str,
250
+ payload: dict[str, Any],
251
+ ) -> None:
252
+ ev = WireEvent(event=event_name, payload=payload)
253
+ await ws.send(ev.model_dump_json())
254
+
255
+
256
+ __all__ = ["WireServer"]
@@ -0,0 +1 @@
1
+ """Hook engine — lifecycle event intercepts."""
@@ -0,0 +1,79 @@
1
+ """
2
+ Hook engine — dispatches lifecycle events to registered handlers.
3
+
4
+ Registration pattern mirrors the tool registry. Plugins call
5
+ `engine.register(HookSpec(...))` at load time. At runtime the agent
6
+ loop emits events via `engine.fire(HookEvent.X, ctx)`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import re
13
+ from collections import defaultdict
14
+
15
+ from opencomputer.hooks.runner import fire_and_forget
16
+ from plugin_sdk.hooks import HookContext, HookDecision, HookEvent, HookSpec
17
+
18
+ logger = logging.getLogger("opencomputer.hooks")
19
+
20
+
21
+ class HookEngine:
22
+ """Central dispatcher for lifecycle events."""
23
+
24
+ def __init__(self) -> None:
25
+ self._hooks: dict[HookEvent, list[HookSpec]] = defaultdict(list)
26
+
27
+ def register(self, spec: HookSpec) -> None:
28
+ self._hooks[spec.event].append(spec)
29
+
30
+ def unregister_all(self, event: HookEvent | None = None) -> None:
31
+ if event is None:
32
+ self._hooks.clear()
33
+ else:
34
+ self._hooks[event] = []
35
+
36
+ def _matches(self, spec: HookSpec, ctx: HookContext) -> bool:
37
+ if spec.matcher is None:
38
+ return True
39
+ # Matcher is a regex over tool name (for PreToolUse / PostToolUse)
40
+ tool_name = ""
41
+ if ctx.tool_call:
42
+ tool_name = ctx.tool_call.name
43
+ elif ctx.tool_result:
44
+ tool_name = ctx.tool_result.tool_call_id # fallback — not ideal
45
+ try:
46
+ return re.search(spec.matcher, tool_name) is not None
47
+ except re.error:
48
+ return False
49
+
50
+ async def fire_blocking(self, ctx: HookContext) -> HookDecision | None:
51
+ """Fire a hook event and WAIT for decisions (for PreToolUse approvals).
52
+
53
+ Returns the first non-pass decision, or None if all hooks passed.
54
+ """
55
+ for spec in self._hooks.get(ctx.event, []):
56
+ if not self._matches(spec, ctx):
57
+ continue
58
+ try:
59
+ decision = await spec.handler(ctx)
60
+ except Exception: # noqa: BLE001
61
+ logger.exception("blocking hook raised")
62
+ continue
63
+ if decision is None or decision.decision == "pass":
64
+ continue
65
+ return decision
66
+ return None
67
+
68
+ def fire_and_forget(self, ctx: HookContext) -> None:
69
+ """Fire a hook event without waiting. Used for PostToolUse logging etc."""
70
+ for spec in self._hooks.get(ctx.event, []):
71
+ if not self._matches(spec, ctx):
72
+ continue
73
+ fire_and_forget(spec.handler(ctx))
74
+
75
+
76
+ engine = HookEngine()
77
+
78
+
79
+ __all__ = ["HookEngine", "engine"]
@@ -0,0 +1,42 @@
1
+ """
2
+ Fire-and-forget async runner.
3
+
4
+ Adapted from kimi-cli's pattern: post-action hooks must NEVER block
5
+ the main loop. We schedule them as independent tasks and log any
6
+ exceptions (never re-raise).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import logging
13
+ from collections.abc import Coroutine
14
+ from typing import Any
15
+
16
+ logger = logging.getLogger("opencomputer.hooks.runner")
17
+
18
+ _pending: set[asyncio.Task[Any]] = set()
19
+
20
+
21
+ def fire_and_forget(coro: Coroutine[Any, Any, Any]) -> None:
22
+ """Schedule `coro` to run independently. Exceptions are logged, never raised."""
23
+ try:
24
+ task = asyncio.create_task(coro)
25
+ except RuntimeError:
26
+ # No running event loop — run synchronously on the current thread as a last resort.
27
+ asyncio.run(coro)
28
+ return
29
+ _pending.add(task)
30
+ task.add_done_callback(_on_done)
31
+
32
+
33
+ def _on_done(task: asyncio.Task[Any]) -> None:
34
+ _pending.discard(task)
35
+ if task.cancelled():
36
+ return
37
+ exc = task.exception()
38
+ if exc is not None:
39
+ logger.warning("fire-and-forget hook raised: %s", exc)
40
+
41
+
42
+ __all__ = ["fire_and_forget"]
@@ -0,0 +1 @@
1
+ """MCP (Model Context Protocol) integration — connect to external tool servers."""