omega-code 0.4.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 (73) hide show
  1. omega/__init__.py +0 -0
  2. omega/__main__.py +589 -0
  3. omega/artifacts.py +151 -0
  4. omega/checkpoint.py +246 -0
  5. omega/compact.py +106 -0
  6. omega/config.py +285 -0
  7. omega/eval/__init__.py +3 -0
  8. omega/eval/cli.py +127 -0
  9. omega/eval/examples/plan-version-flag.yaml +11 -0
  10. omega/eval/examples/relative-age-negative-delta.yaml +14 -0
  11. omega/eval/examples/version-flag.yaml +10 -0
  12. omega/eval/manifest.py +129 -0
  13. omega/eval/prices.py +29 -0
  14. omega/eval/report.py +135 -0
  15. omega/eval/runner.py +199 -0
  16. omega/eval/tasks.py +97 -0
  17. omega/events.py +145 -0
  18. omega/export.py +80 -0
  19. omega/gitlog.py +229 -0
  20. omega/hooks.py +63 -0
  21. omega/instructions.py +103 -0
  22. omega/integrations.py +284 -0
  23. omega/keys.py +173 -0
  24. omega/llm.py +442 -0
  25. omega/loop.py +510 -0
  26. omega/mcp.py +490 -0
  27. omega/memory/__init__.py +5 -0
  28. omega/memory/consolidate.py +103 -0
  29. omega/memory/curate.py +69 -0
  30. omega/memory/store.py +321 -0
  31. omega/memory/tools.py +175 -0
  32. omega/migrate.py +40 -0
  33. omega/onboarding.py +242 -0
  34. omega/permissions.py +137 -0
  35. omega/secrets.py +173 -0
  36. omega/server/__init__.py +7 -0
  37. omega/server/__main__.py +18 -0
  38. omega/server/app.py +71 -0
  39. omega/server/auth.py +73 -0
  40. omega/server/manager.py +287 -0
  41. omega/server/models.py +123 -0
  42. omega/server/tasks_api.py +311 -0
  43. omega/server/terminals.py +245 -0
  44. omega/server/worker.py +186 -0
  45. omega/session.py +209 -0
  46. omega/setup.html +281 -0
  47. omega/setup_server.py +452 -0
  48. omega/skills.py +158 -0
  49. omega/subagent.py +98 -0
  50. omega/tasks.py +195 -0
  51. omega/tools.py +590 -0
  52. omega/trace.py +156 -0
  53. omega/trajectory.py +146 -0
  54. omega/ui/__init__.py +0 -0
  55. omega/ui/composer.py +140 -0
  56. omega/ui/format.py +708 -0
  57. omega/ui/plain.py +141 -0
  58. omega/ui/tui/__init__.py +9 -0
  59. omega/ui/tui/app.py +958 -0
  60. omega/ui/tui/history.py +50 -0
  61. omega/ui/tui/modals.py +292 -0
  62. omega/ui/tui/onboarding.py +367 -0
  63. omega/ui/tui/prefs.py +25 -0
  64. omega/ui/tui/sidebar.py +510 -0
  65. omega/ui/tui/status.py +115 -0
  66. omega/ui/tui/theme.py +91 -0
  67. omega/ui/tui/transcript.py +783 -0
  68. omega/verify.py +133 -0
  69. omega_code-0.4.0.dist-info/METADATA +479 -0
  70. omega_code-0.4.0.dist-info/RECORD +73 -0
  71. omega_code-0.4.0.dist-info/WHEEL +4 -0
  72. omega_code-0.4.0.dist-info/entry_points.txt +2 -0
  73. omega_code-0.4.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,311 @@
1
+ """REST + WebSocket routes for tasks, plus the daemon-wide `/api/models`,
2
+ `/api/connections` and `/api/memory` lookups -- see Phase 9 of the plan for
3
+ the exact contract the SwiftUI app is built against."""
4
+ from __future__ import annotations
5
+
6
+ import asyncio
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from fastapi import APIRouter, Depends, HTTPException, Query, Request, WebSocket, WebSocketDisconnect
11
+
12
+ from .. import artifacts, checkpoint, config, gitlog, mcp, session, tasks, trace
13
+ from ..memory import store
14
+ from . import auth
15
+ from .manager import TaskManager
16
+ from .models import (
17
+ AnswerIn,
18
+ ConfirmIn,
19
+ ConnectionOut,
20
+ HealthOut,
21
+ MemoryHitOut,
22
+ ModeIn,
23
+ ModelCatalogEntry,
24
+ ModelIn,
25
+ PrCreateIn,
26
+ PromptIn,
27
+ TaskCreateIn,
28
+ TaskDetailOut,
29
+ TaskOut,
30
+ UndoIn,
31
+ )
32
+
33
+ router = APIRouter(tags=["tasks"])
34
+
35
+
36
+ def _manager(request: Request) -> TaskManager:
37
+ mgr: TaskManager = request.app.state.tasks_manager
38
+ return mgr
39
+
40
+
41
+ def _get_task_or_404(task_id: str) -> tasks.Task:
42
+ task = tasks.get(task_id)
43
+ if task is None:
44
+ raise HTTPException(status_code=404, detail="no such task")
45
+ return task
46
+
47
+
48
+ @router.get("/api/health")
49
+ async def health(request: Request) -> HealthOut:
50
+ return HealthOut(version=request.app.state.version)
51
+
52
+
53
+ # -- tasks CRUD -----------------------------------------------------------
54
+
55
+ @router.get("/api/tasks", dependencies=[Depends(auth.require_token)])
56
+ async def list_tasks_route() -> list[TaskOut]:
57
+ return [TaskOut.from_task(t) for t in tasks.list_tasks()]
58
+
59
+
60
+ @router.post("/api/tasks", dependencies=[Depends(auth.require_token)])
61
+ async def create_task(body: TaskCreateIn, request: Request) -> TaskOut:
62
+ try:
63
+ task = await asyncio.to_thread(
64
+ tasks.create, body.repo, body.prompt, body.worktree, body.model, body.mode)
65
+ except (ValueError, RuntimeError) as e:
66
+ raise HTTPException(status_code=400, detail=str(e)) from e
67
+ _manager(request).publish_overview_task(task)
68
+ if body.prompt:
69
+ await _manager(request).send_prompt(task, body.prompt)
70
+ task = _get_task_or_404(task.id)
71
+ return TaskOut.from_task(task)
72
+
73
+
74
+ @router.get("/api/tasks/{task_id}", dependencies=[Depends(auth.require_token)])
75
+ async def get_task(task_id: str) -> TaskDetailOut:
76
+ task = _get_task_or_404(task_id)
77
+ sess = session.load(task.id)
78
+ return TaskDetailOut(**TaskOut.from_task(task).model_dump(), history=sess.history)
79
+
80
+
81
+ @router.delete("/api/tasks/{task_id}", dependencies=[Depends(auth.require_token)])
82
+ async def delete_task(task_id: str, request: Request,
83
+ delete_worktree: bool = Query(False)) -> dict[str, bool]:
84
+ _get_task_or_404(task_id)
85
+ await _manager(request).shutdown_task(task_id)
86
+ ok = tasks.remove(task_id, delete_worktree=delete_worktree)
87
+ return {"deleted": ok}
88
+
89
+
90
+ # -- turn control -----------------------------------------------------------
91
+
92
+ @router.post("/api/tasks/{task_id}/prompt", dependencies=[Depends(auth.require_token)])
93
+ async def send_prompt(task_id: str, body: PromptIn, request: Request) -> TaskOut:
94
+ task = _get_task_or_404(task_id)
95
+ if task.title == "(no prompt yet)":
96
+ task = tasks.update(task_id, title=tasks.title_from_prompt(body.text)) or task
97
+ await _manager(request).send_prompt(task, body.text)
98
+ return TaskOut.from_task(_get_task_or_404(task_id))
99
+
100
+
101
+ @router.post("/api/tasks/{task_id}/cancel", dependencies=[Depends(auth.require_token)])
102
+ async def cancel_task(task_id: str, request: Request) -> dict[str, bool]:
103
+ _get_task_or_404(task_id)
104
+ ok = await _manager(request).cancel(task_id)
105
+ return {"cancelled": ok}
106
+
107
+
108
+ @router.post("/api/tasks/{task_id}/answer", dependencies=[Depends(auth.require_token)])
109
+ async def answer_task(task_id: str, body: AnswerIn, request: Request) -> dict[str, bool]:
110
+ _get_task_or_404(task_id)
111
+ ok = await _manager(request).send_answer(task_id, body.request_id, body.answer)
112
+ return {"sent": ok}
113
+
114
+
115
+ @router.post("/api/tasks/{task_id}/confirm", dependencies=[Depends(auth.require_token)])
116
+ async def confirm_task(task_id: str, body: ConfirmIn, request: Request) -> dict[str, bool]:
117
+ _get_task_or_404(task_id)
118
+ ok = await _manager(request).send_confirm(task_id, body.request_id, body.allow)
119
+ return {"sent": ok}
120
+
121
+
122
+ @router.post("/api/tasks/{task_id}/model", dependencies=[Depends(auth.require_token)])
123
+ async def set_model(task_id: str, body: ModelIn, request: Request) -> TaskOut:
124
+ task = _get_task_or_404(task_id)
125
+ task = tasks.update(task_id, model=body.model) or task
126
+ await _manager(request).set_model(task_id, body.model)
127
+ return TaskOut.from_task(task)
128
+
129
+
130
+ @router.post("/api/tasks/{task_id}/mode", dependencies=[Depends(auth.require_token)])
131
+ async def set_mode(task_id: str, body: ModeIn, request: Request) -> TaskOut:
132
+ task = _get_task_or_404(task_id)
133
+ task = tasks.update(task_id, mode=body.mode) or task
134
+ await _manager(request).set_mode(task_id, body.mode)
135
+ return TaskOut.from_task(task)
136
+
137
+
138
+ # -- git / checkpoints --------------------------------------------------
139
+
140
+ @router.get("/api/tasks/{task_id}/git", dependencies=[Depends(auth.require_token)])
141
+ async def task_git(task_id: str) -> dict[str, Any]:
142
+ task = _get_task_or_404(task_id)
143
+ repos = await gitlog.discover_repos_async(Path(task.cwd), max_depth=0)
144
+ if not repos:
145
+ return {"branch": None, "dirty": False, "changes": []}
146
+ repo = repos[0]
147
+ changes = await gitlog.working_tree_async(repo)
148
+ return {
149
+ "branch": repo.branch, "dirty": repo.dirty,
150
+ "changes": [{"path": c.path, "status": c.status, "added": c.added, "removed": c.removed}
151
+ for c in changes],
152
+ }
153
+
154
+
155
+ @router.get("/api/tasks/{task_id}/diff", dependencies=[Depends(auth.require_token)])
156
+ async def task_diff(task_id: str, path: str | None = None) -> dict[str, str]:
157
+ task = _get_task_or_404(task_id)
158
+ if path:
159
+ repos = await gitlog.discover_repos_async(Path(task.cwd), max_depth=0)
160
+ text = await gitlog.diff_async(repos[0], path) if repos else ""
161
+ else:
162
+ text = await asyncio.to_thread(checkpoint.diff, task.id, None, task.cwd)
163
+ return {"diff": text}
164
+
165
+
166
+ @router.post("/api/tasks/{task_id}/undo", dependencies=[Depends(auth.require_token)])
167
+ async def task_undo(task_id: str, body: UndoIn) -> dict[str, str]:
168
+ task = _get_task_or_404(task_id)
169
+ result = await asyncio.to_thread(checkpoint.undo, task.id, body.steps, task.cwd)
170
+ return {"result": result}
171
+
172
+
173
+ # -- artifacts / trace / jobs --------------------------------------------
174
+
175
+ @router.get("/api/tasks/{task_id}/artifacts", dependencies=[Depends(auth.require_token)])
176
+ async def task_artifacts(task_id: str) -> list[dict[str, Any]]:
177
+ task = _get_task_or_404(task_id)
178
+ return artifacts.list_artifacts(task.id)
179
+
180
+
181
+ @router.get("/api/tasks/{task_id}/artifacts/{artifact_id}", dependencies=[Depends(auth.require_token)])
182
+ async def task_artifact(task_id: str, artifact_id: str,
183
+ offset: int = 0, limit: int = 0) -> dict[str, str]:
184
+ task = _get_task_or_404(task_id)
185
+ text = artifacts.fetch(task.id, artifact_id, offset, limit or artifacts.PAGE_CHARS)
186
+ return {"content": text}
187
+
188
+
189
+ @router.get("/api/tasks/{task_id}/trace", dependencies=[Depends(auth.require_token)])
190
+ async def task_trace(task_id: str) -> dict[str, str]:
191
+ task = _get_task_or_404(task_id)
192
+ return {"trace": trace.render_timeline(task.id, raw_json=True)}
193
+
194
+
195
+ @router.get("/api/tasks/{task_id}/jobs", dependencies=[Depends(auth.require_token)])
196
+ async def task_jobs(task_id: str, request: Request) -> list[dict[str, Any]]:
197
+ _get_task_or_404(task_id)
198
+ return await _manager(request).get_jobs(task_id)
199
+
200
+
201
+ # -- pull requests --------------------------------------------------------
202
+
203
+ @router.post("/api/tasks/{task_id}/pr", dependencies=[Depends(auth.require_token)])
204
+ async def create_pr(task_id: str, body: PrCreateIn) -> dict[str, Any]:
205
+ task = _get_task_or_404(task_id)
206
+ if not task.branch:
207
+ raise HTTPException(status_code=400, detail="task has no branch (not a worktree task)")
208
+ args = ["gh", "pr", "create", "--head", task.branch,
209
+ "--title", body.title or task.title, "--body", body.body or ""]
210
+ if body.draft:
211
+ args.append("--draft")
212
+ proc = await asyncio.create_subprocess_exec(
213
+ *args, cwd=task.repo, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
214
+ stdout, stderr = await proc.communicate()
215
+ if proc.returncode != 0:
216
+ raise HTTPException(status_code=502, detail=stderr.decode(errors="replace")[:500])
217
+ info = await asyncio.to_thread(tasks.pr_info, task)
218
+ if info is not None:
219
+ tasks.update(task_id, pr=info)
220
+ return {"url": stdout.decode().strip(), "pr": info}
221
+
222
+
223
+ # -- daemon-wide lookups --------------------------------------------------
224
+
225
+ @router.get("/api/models", dependencies=[Depends(auth.require_token)])
226
+ async def list_models() -> list[ModelCatalogEntry]:
227
+ cfg = config.load()
228
+ return [ModelCatalogEntry(alias=alias, model=m.model, provider=m.provider, context=m.context)
229
+ for alias, m in sorted(cfg.models.items())]
230
+
231
+
232
+ @router.get("/api/connections", dependencies=[Depends(auth.require_token)])
233
+ async def list_connections() -> list[ConnectionOut]:
234
+ return [ConnectionOut(name=name, enabled=st.enabled, state=st.state, tools=st.tools,
235
+ error=st.error, last_used=st.last_used)
236
+ for name, st in sorted(mcp.status().items())]
237
+
238
+
239
+ @router.post("/api/connections/{name}/connect", dependencies=[Depends(auth.require_token)])
240
+ async def connect_connection(name: str) -> ConnectionOut:
241
+ st = await mcp.connect(name)
242
+ return ConnectionOut(name=st.name, enabled=st.enabled, state=st.state, tools=st.tools,
243
+ error=st.error, last_used=st.last_used)
244
+
245
+
246
+ @router.get("/api/memory", dependencies=[Depends(auth.require_token)])
247
+ async def query_memory(q: str, scope: str = "both", type: str | None = None,
248
+ limit: int = 8, task_id: str | None = None) -> list[MemoryHitOut]:
249
+ cwd = _get_task_or_404(task_id).cwd if task_id else None
250
+ scopes = ["project", "global"] if scope == "both" else [scope]
251
+ hits: list[MemoryHitOut] = []
252
+ for sc in scopes:
253
+ if sc == "project" and not store.db_exists("project", cwd):
254
+ continue
255
+ try:
256
+ rows = await asyncio.to_thread(store.search, sc, q, type, limit, False, cwd)
257
+ except ValueError as e:
258
+ raise HTTPException(status_code=400, detail=str(e)) from e
259
+ hits.extend(MemoryHitOut(id=r["id"], scope=sc, type=r["type"], title=r["title"],
260
+ body=r["body"], confidence=r["confidence"],
261
+ importance=r["importance"]) for r in rows)
262
+ return hits[:limit]
263
+
264
+
265
+ # -- websockets -----------------------------------------------------------
266
+
267
+ async def _relay_events(websocket: WebSocket, queue: asyncio.Queue[str]) -> None:
268
+ """Both `/ws/tasks/{id}` and `/ws/overview` are server-push-only from the
269
+ app's side. A send on a connection the client has closed raises
270
+ `WebSocketDisconnect` (uvicorn tears the ASGI channel down on the
271
+ client's close frame), which the caller catches -- so the loop ends
272
+ without a separate `receive()`-based watchdog."""
273
+ while True:
274
+ await websocket.send_text(await queue.get())
275
+
276
+
277
+ @router.websocket("/ws/tasks/{task_id}")
278
+ async def task_ws(websocket: WebSocket, task_id: str) -> None:
279
+ if not auth.check_ws_token(websocket):
280
+ await websocket.close(code=1008)
281
+ return
282
+ if tasks.get(task_id) is None:
283
+ await websocket.close(code=1008, reason="no such task")
284
+ return
285
+
286
+ mgr: TaskManager = websocket.app.state.tasks_manager
287
+ await websocket.accept()
288
+ queue = mgr.subscribe_task(task_id)
289
+ try:
290
+ await _relay_events(websocket, queue)
291
+ except WebSocketDisconnect:
292
+ pass
293
+ finally:
294
+ mgr.unsubscribe_task(task_id, queue)
295
+
296
+
297
+ @router.websocket("/ws/overview")
298
+ async def overview_ws(websocket: WebSocket) -> None:
299
+ if not auth.check_ws_token(websocket):
300
+ await websocket.close(code=1008)
301
+ return
302
+
303
+ mgr: TaskManager = websocket.app.state.tasks_manager
304
+ await websocket.accept()
305
+ queue = mgr.subscribe_overview()
306
+ try:
307
+ await _relay_events(websocket, queue)
308
+ except WebSocketDisconnect:
309
+ pass
310
+ finally:
311
+ mgr.unsubscribe_overview(queue)
@@ -0,0 +1,245 @@
1
+ """Server-side PTY terminals: `pty.fork()` runs the user's login shell, kept
2
+ alive for the life of the daemon (surviving Omega.app restarts, per Phase 9
3
+ of the plan) and relayed byte-for-byte over `/ws/terminals/{id}`."""
4
+ from __future__ import annotations
5
+
6
+ import asyncio
7
+ import fcntl
8
+ import json
9
+ import os
10
+ import pty
11
+ import secrets
12
+ import signal
13
+ import struct
14
+ import termios
15
+ import time
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+
19
+ from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect
20
+
21
+ from . import auth
22
+ from .models import TerminalCreateIn, TerminalOut
23
+
24
+ READ_CHUNK = 4096
25
+
26
+
27
+ @dataclass
28
+ class Terminal:
29
+ id: str
30
+ task_id: str | None
31
+ cwd: str
32
+ created: float
33
+ pid: int
34
+ master_fd: int
35
+ subscribers: set[asyncio.Queue[bytes]] = field(default_factory=set)
36
+ reader_task: asyncio.Task[None] | None = None
37
+ alive: bool = True
38
+
39
+ def to_out(self) -> TerminalOut:
40
+ return TerminalOut(id=self.id, task_id=self.task_id, pid=self.pid,
41
+ cwd=self.cwd, created=self.created)
42
+
43
+
44
+ class TerminalManager:
45
+ def __init__(self) -> None:
46
+ self._terminals: dict[str, Terminal] = {}
47
+
48
+ def list(self) -> list[Terminal]:
49
+ return sorted(self._terminals.values(), key=lambda t: t.created)
50
+
51
+ def get(self, terminal_id: str) -> Terminal | None:
52
+ return self._terminals.get(terminal_id)
53
+
54
+ def create(self, task_id: str | None, cwd: str | None) -> Terminal:
55
+ work_dir = os.path.expanduser(cwd) if cwd else str(Path.home())
56
+ if not Path(work_dir).is_dir():
57
+ raise ValueError(f"{work_dir!r} is not a directory")
58
+ shell = os.environ.get("SHELL", "/bin/zsh")
59
+
60
+ pid, master_fd = pty.fork()
61
+ if pid == 0:
62
+ # Child: exec the login shell in the requested cwd. os._exit on
63
+ # any failure here -- a raised exception in the forked child would
64
+ # otherwise re-enter the parent's (already-forked) asyncio state.
65
+ try:
66
+ os.chdir(work_dir)
67
+ os.execvp(shell, [shell, "-l"])
68
+ except Exception:
69
+ os._exit(1)
70
+
71
+ term = Terminal(id=secrets.token_hex(6), task_id=task_id, cwd=work_dir,
72
+ created=time.time(), pid=pid, master_fd=master_fd)
73
+ self._terminals[term.id] = term
74
+ term.reader_task = asyncio.create_task(self._pump(term))
75
+ return term
76
+
77
+ async def _pump(self, term: Terminal) -> None:
78
+ """One background reader per terminal, fanning bytes out to every
79
+ subscribed WS connection -- lets several viewers watch the same shell."""
80
+ loop = asyncio.get_event_loop()
81
+ try:
82
+ while True:
83
+ try:
84
+ data = await loop.run_in_executor(None, os.read, term.master_fd, READ_CHUNK)
85
+ except OSError:
86
+ break
87
+ if not data:
88
+ break
89
+ for q in term.subscribers:
90
+ q.put_nowait(data)
91
+ finally:
92
+ term.alive = False
93
+
94
+ def write(self, terminal_id: str, data: bytes) -> bool:
95
+ term = self._terminals.get(terminal_id)
96
+ if term is None or not term.alive:
97
+ return False
98
+ try:
99
+ os.write(term.master_fd, data)
100
+ except OSError:
101
+ return False
102
+ return True
103
+
104
+ def resize(self, terminal_id: str, cols: int, rows: int) -> bool:
105
+ term = self._terminals.get(terminal_id)
106
+ if term is None or not term.alive:
107
+ return False
108
+ packed = struct.pack("HHHH", rows, cols, 0, 0)
109
+ try:
110
+ fcntl.ioctl(term.master_fd, termios.TIOCSWINSZ, packed)
111
+ except OSError:
112
+ return False
113
+ return True
114
+
115
+ def subscribe(self, terminal_id: str) -> asyncio.Queue[bytes] | None:
116
+ term = self._terminals.get(terminal_id)
117
+ if term is None:
118
+ return None
119
+ q: asyncio.Queue[bytes] = asyncio.Queue()
120
+ term.subscribers.add(q)
121
+ return q
122
+
123
+ def unsubscribe(self, terminal_id: str, q: asyncio.Queue[bytes]) -> None:
124
+ term = self._terminals.get(terminal_id)
125
+ if term is not None:
126
+ term.subscribers.discard(q)
127
+
128
+ def kill(self, terminal_id: str) -> bool:
129
+ term = self._terminals.pop(terminal_id, None)
130
+ if term is None:
131
+ return False
132
+ if term.reader_task is not None:
133
+ term.reader_task.cancel()
134
+ try:
135
+ os.killpg(os.getpgid(term.pid), signal.SIGHUP)
136
+ except (ProcessLookupError, PermissionError):
137
+ pass
138
+ try:
139
+ os.close(term.master_fd)
140
+ except OSError:
141
+ pass
142
+ return True
143
+
144
+ def kill_all(self) -> None:
145
+ for terminal_id in list(self._terminals):
146
+ self.kill(terminal_id)
147
+
148
+
149
+ router = APIRouter(tags=["terminals"])
150
+
151
+
152
+ def _manager(request: Request) -> TerminalManager:
153
+ mgr: TerminalManager = request.app.state.terminals
154
+ return mgr
155
+
156
+
157
+ @router.get("/api/terminals", dependencies=[Depends(auth.require_token)])
158
+ async def list_terminals(request: Request) -> list[TerminalOut]:
159
+ return [t.to_out() for t in _manager(request).list()]
160
+
161
+
162
+ @router.post("/api/terminals", dependencies=[Depends(auth.require_token)])
163
+ async def create_terminal(body: TerminalCreateIn, request: Request) -> TerminalOut:
164
+ try:
165
+ term = _manager(request).create(body.task_id, body.cwd)
166
+ except ValueError as e:
167
+ raise HTTPException(status_code=400, detail=str(e)) from e
168
+ await _publish_overview(request)
169
+ return term.to_out()
170
+
171
+
172
+ @router.delete("/api/terminals/{terminal_id}", dependencies=[Depends(auth.require_token)])
173
+ async def delete_terminal(terminal_id: str, request: Request) -> dict[str, bool]:
174
+ ok = _manager(request).kill(terminal_id)
175
+ if not ok:
176
+ raise HTTPException(status_code=404, detail="no such terminal")
177
+ await _publish_overview(request)
178
+ return {"deleted": True}
179
+
180
+
181
+ async def _publish_overview(request: Request) -> None:
182
+ from .manager import TaskManager
183
+ tasks_mgr: TaskManager = request.app.state.tasks_manager
184
+ terms = [t.to_out().model_dump() for t in _manager(request).list()]
185
+ tasks_mgr.publish_overview_terminals(terms)
186
+
187
+
188
+ @router.websocket("/ws/terminals/{terminal_id}")
189
+ async def terminal_ws(websocket: WebSocket, terminal_id: str) -> None:
190
+ if not auth.check_ws_token(websocket):
191
+ await websocket.close(code=1008)
192
+ return
193
+ mgr: TerminalManager = websocket.app.state.terminals
194
+ term = mgr.get(terminal_id)
195
+ if term is None:
196
+ await websocket.close(code=1008, reason="no such terminal")
197
+ return
198
+
199
+ await websocket.accept()
200
+ queue = mgr.subscribe(terminal_id)
201
+ assert queue is not None
202
+
203
+ async def pump_out() -> None:
204
+ while True:
205
+ data = await queue.get()
206
+ await websocket.send_bytes(data)
207
+
208
+ sender = asyncio.create_task(pump_out())
209
+ try:
210
+ while True:
211
+ message = await websocket.receive()
212
+ if message.get("type") == "websocket.disconnect":
213
+ break
214
+ data = message.get("bytes")
215
+ if data is not None:
216
+ mgr.write(terminal_id, data)
217
+ continue
218
+ text = message.get("text")
219
+ if text is None:
220
+ continue
221
+ resized = _try_resize(text)
222
+ if resized is not None:
223
+ mgr.resize(terminal_id, *resized)
224
+ else:
225
+ mgr.write(terminal_id, text.encode())
226
+ except WebSocketDisconnect:
227
+ pass
228
+ finally:
229
+ sender.cancel()
230
+ mgr.unsubscribe(terminal_id, queue)
231
+
232
+
233
+ def _try_resize(text: str) -> tuple[int, int] | None:
234
+ try:
235
+ obj = json.loads(text)
236
+ except json.JSONDecodeError:
237
+ return None
238
+ resize = obj.get("resize") if isinstance(obj, dict) else None
239
+ if not (isinstance(resize, list) and len(resize) == 2):
240
+ return None
241
+ try:
242
+ cols, rows = int(resize[0]), int(resize[1])
243
+ except (TypeError, ValueError):
244
+ return None
245
+ return cols, rows