tamfis-code 0.2.3__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.
- tamfis_code/__init__.py +58 -0
- tamfis_code/__main__.py +4 -0
- tamfis_code/agents.py +371 -0
- tamfis_code/api_client.py +461 -0
- tamfis_code/cli.py +2351 -0
- tamfis_code/completion.py +137 -0
- tamfis_code/config.py +199 -0
- tamfis_code/doctor.py +173 -0
- tamfis_code/enforcer.py +318 -0
- tamfis_code/indexer.py +567 -0
- tamfis_code/instructions.py +121 -0
- tamfis_code/interactive.py +985 -0
- tamfis_code/local_chat.py +117 -0
- tamfis_code/local_tools.py +93 -0
- tamfis_code/mcp.py +538 -0
- tamfis_code/metrics.py +105 -0
- tamfis_code/providers.py +423 -0
- tamfis_code/references.py +336 -0
- tamfis_code/render.py +575 -0
- tamfis_code/runner.py +635 -0
- tamfis_code/runner_local.py +364 -0
- tamfis_code/safety.py +164 -0
- tamfis_code/screenshot.py +382 -0
- tamfis_code/sessions.py +253 -0
- tamfis_code/state.py +449 -0
- tamfis_code/tasks.py +42 -0
- tamfis_code/workspace.py +329 -0
- tamfis_code-0.2.3.dist-info/METADATA +78 -0
- tamfis_code-0.2.3.dist-info/RECORD +32 -0
- tamfis_code-0.2.3.dist-info/WHEEL +5 -0
- tamfis_code-0.2.3.dist-info/entry_points.txt +4 -0
- tamfis_code-0.2.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
"""Thin async client for the TamfisGPT Remote API.
|
|
2
|
+
|
|
3
|
+
This is the ONLY place TamfisGPT Code talks to the backend. It calls the
|
|
4
|
+
exact same endpoints the web frontend's remoteApi.ts calls (tier_ii_gateway/
|
|
5
|
+
api/remote.py) -- no separate CLI-only provider/tool/session logic, per
|
|
6
|
+
docs/REMOTE_AGENT_MASTER_SPEC.md Phase 21's "shared runtime contract"
|
|
7
|
+
requirement.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import mimetypes
|
|
14
|
+
import os
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, AsyncIterator, Optional
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
from .config import (
|
|
21
|
+
CREDENTIALS_PATH, Config, Credentials, clear_credentials as _clear_file_credentials,
|
|
22
|
+
load_credentials as _load_file_credentials, save_credentials as _save_file_credentials,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
KEYRING_SERVICE = "tamfis-code"
|
|
26
|
+
KEYRING_ACCOUNT = "default"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _keyring_module():
|
|
30
|
+
try:
|
|
31
|
+
import keyring # type: ignore
|
|
32
|
+
return keyring
|
|
33
|
+
except (ImportError, RuntimeError):
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def credential_storage_backend() -> str:
|
|
38
|
+
return "system-keyring" if _keyring_module() is not None else f"owner-only-file ({CREDENTIALS_PATH})"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def load_secure_credentials() -> Optional[Credentials]:
|
|
42
|
+
# Preserve the documented environment-token override without copying it
|
|
43
|
+
# into keyring or disk.
|
|
44
|
+
if os.environ.get("TAMFIS_CODE_TOKEN"):
|
|
45
|
+
return _load_file_credentials()
|
|
46
|
+
keyring = _keyring_module()
|
|
47
|
+
if keyring is not None:
|
|
48
|
+
try:
|
|
49
|
+
raw = keyring.get_password(KEYRING_SERVICE, KEYRING_ACCOUNT)
|
|
50
|
+
if raw:
|
|
51
|
+
data = json.loads(raw)
|
|
52
|
+
if data.get("access_token"):
|
|
53
|
+
return Credentials(**{key: data.get(key) for key in (
|
|
54
|
+
"access_token", "refresh_token", "user_id", "email"
|
|
55
|
+
)})
|
|
56
|
+
except Exception:
|
|
57
|
+
# Headless Secret Service installations often import correctly
|
|
58
|
+
# but have no unlocked collection. The 0600 fallback is the
|
|
59
|
+
# intentional supported path there.
|
|
60
|
+
pass
|
|
61
|
+
return _load_file_credentials()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def save_secure_credentials(creds: Credentials) -> str:
|
|
65
|
+
keyring = _keyring_module()
|
|
66
|
+
if keyring is not None:
|
|
67
|
+
try:
|
|
68
|
+
keyring.set_password(KEYRING_SERVICE, KEYRING_ACCOUNT, json.dumps({
|
|
69
|
+
"access_token": creds.access_token, "refresh_token": creds.refresh_token,
|
|
70
|
+
"user_id": creds.user_id, "email": creds.email,
|
|
71
|
+
}))
|
|
72
|
+
_clear_file_credentials()
|
|
73
|
+
return "system-keyring"
|
|
74
|
+
except Exception:
|
|
75
|
+
pass
|
|
76
|
+
_save_file_credentials(creds)
|
|
77
|
+
return "owner-only-file"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def clear_secure_credentials() -> bool:
|
|
81
|
+
removed = _clear_file_credentials()
|
|
82
|
+
keyring = _keyring_module()
|
|
83
|
+
if keyring is not None:
|
|
84
|
+
try:
|
|
85
|
+
keyring.delete_password(KEYRING_SERVICE, KEYRING_ACCOUNT)
|
|
86
|
+
removed = True
|
|
87
|
+
except Exception:
|
|
88
|
+
pass
|
|
89
|
+
return removed
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class RemoteAPIError(RuntimeError):
|
|
93
|
+
def __init__(self, status_code: int, detail: str):
|
|
94
|
+
super().__init__(detail)
|
|
95
|
+
self.status_code = status_code
|
|
96
|
+
self.detail = detail
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class AuthRequiredError(RemoteAPIError):
|
|
100
|
+
"""401 that survived a refresh attempt (or no credentials at all) --
|
|
101
|
+
the caller must run `tamfis-code login`."""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _api_root(api_base: str) -> str:
|
|
105
|
+
return api_base.rstrip("/") + "/api/v1"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class RemoteAPIClient:
|
|
109
|
+
def __init__(self, config: Config, credentials: Optional[Credentials] = None):
|
|
110
|
+
self.config = config
|
|
111
|
+
self.credentials = credentials or load_secure_credentials()
|
|
112
|
+
self._client = httpx.AsyncClient(timeout=config.timeout_seconds)
|
|
113
|
+
|
|
114
|
+
async def aclose(self) -> None:
|
|
115
|
+
await self._client.aclose()
|
|
116
|
+
|
|
117
|
+
async def __aenter__(self) -> "RemoteAPIClient":
|
|
118
|
+
return self
|
|
119
|
+
|
|
120
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
121
|
+
await self.aclose()
|
|
122
|
+
|
|
123
|
+
# -- low-level request plumbing -----------------------------------
|
|
124
|
+
|
|
125
|
+
def _headers(self) -> dict[str, str]:
|
|
126
|
+
if not self.credentials:
|
|
127
|
+
return {}
|
|
128
|
+
return {"Authorization": f"Bearer {self.credentials.access_token}"}
|
|
129
|
+
|
|
130
|
+
async def _refresh(self) -> bool:
|
|
131
|
+
if not self.credentials or not self.credentials.refresh_token:
|
|
132
|
+
return False
|
|
133
|
+
# /auth/refresh reads the refresh token ONLY from the TAMFIS_REFRESH
|
|
134
|
+
# cookie (tier_ii_gateway/dependencies/auth.py's refresh_token()) --
|
|
135
|
+
# a JSON body is silently ignored. The browser client relies on the
|
|
136
|
+
# httpOnly Set-Cookie from /auth/login; the CLI has no cookie jar
|
|
137
|
+
# persisted across processes, so it sends the stored refresh token
|
|
138
|
+
# as that same cookie explicitly on just this one request.
|
|
139
|
+
url = f"{_api_root(self.config.api_base)}/auth/refresh"
|
|
140
|
+
try:
|
|
141
|
+
# Set the Cookie header directly rather than httpx's per-request
|
|
142
|
+
# `cookies=` kwarg -- that's deprecated on a shared client (its
|
|
143
|
+
# interaction with the client's own persistent cookie jar is
|
|
144
|
+
# ambiguous); this request needs exactly one cookie and nothing
|
|
145
|
+
# from the jar, so there's no ambiguity to have.
|
|
146
|
+
resp = await self._client.post(
|
|
147
|
+
url,
|
|
148
|
+
headers={
|
|
149
|
+
"Cookie": f"TAMFIS_REFRESH={self.credentials.refresh_token}",
|
|
150
|
+
"X-Tamfis-Client": "tamfis-code",
|
|
151
|
+
},
|
|
152
|
+
)
|
|
153
|
+
except httpx.HTTPError:
|
|
154
|
+
return False
|
|
155
|
+
if resp.status_code != 200:
|
|
156
|
+
return False
|
|
157
|
+
data = _unwrap(resp.json())
|
|
158
|
+
access_token = data.get("access_token")
|
|
159
|
+
if not access_token:
|
|
160
|
+
return False
|
|
161
|
+
self.credentials = Credentials(
|
|
162
|
+
access_token=access_token,
|
|
163
|
+
refresh_token=data.get("refresh_token", self.credentials.refresh_token),
|
|
164
|
+
user_id=self.credentials.user_id,
|
|
165
|
+
email=self.credentials.email,
|
|
166
|
+
)
|
|
167
|
+
save_secure_credentials(self.credentials)
|
|
168
|
+
return True
|
|
169
|
+
|
|
170
|
+
async def request(
|
|
171
|
+
self, method: str, path: str, *, json_body: Optional[dict[str, Any]] = None,
|
|
172
|
+
params: Optional[dict[str, Any]] = None, _retried: bool = False,
|
|
173
|
+
) -> Any:
|
|
174
|
+
url = f"{_api_root(self.config.api_base)}{path}"
|
|
175
|
+
resp = await self._client.request(method, url, json=json_body, params=params, headers=self._headers())
|
|
176
|
+
|
|
177
|
+
if resp.status_code == 401 and not _retried:
|
|
178
|
+
if await self._refresh():
|
|
179
|
+
return await self.request(method, path, json_body=json_body, params=params, _retried=True)
|
|
180
|
+
raise AuthRequiredError(401, "Not authenticated -- run `tamfis-code login`")
|
|
181
|
+
|
|
182
|
+
if resp.status_code >= 400:
|
|
183
|
+
detail = _error_detail(resp)
|
|
184
|
+
if resp.status_code == 401:
|
|
185
|
+
raise AuthRequiredError(401, detail)
|
|
186
|
+
raise RemoteAPIError(resp.status_code, detail)
|
|
187
|
+
|
|
188
|
+
if not resp.content:
|
|
189
|
+
return None
|
|
190
|
+
return _unwrap(resp.json())
|
|
191
|
+
|
|
192
|
+
# -- auth -----------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
async def login(self, email: str, password: str) -> dict[str, Any]:
|
|
195
|
+
url = f"{_api_root(self.config.api_base)}/auth/login"
|
|
196
|
+
# remember_me=True: a CLI session is inherently long-lived and
|
|
197
|
+
# single-user, not a shared browser -- the short (minutes-scale)
|
|
198
|
+
# default token expiry meant a real interactive session (the kind
|
|
199
|
+
# multi-step engineering work actually needs) would silently start
|
|
200
|
+
# 401ing mid-task after normal working-session lengths, forcing a
|
|
201
|
+
# re-login with no warning. remember_me buys the full 30-day expiry
|
|
202
|
+
# tier_ii_gateway/dependencies/auth.py already defines for exactly
|
|
203
|
+
# this case.
|
|
204
|
+
resp = await self._client.post(
|
|
205
|
+
url,
|
|
206
|
+
json={"email": email, "password": password, "remember_me": True},
|
|
207
|
+
headers={"X-Tamfis-Client": "tamfis-code"},
|
|
208
|
+
)
|
|
209
|
+
if resp.status_code >= 400:
|
|
210
|
+
raise RemoteAPIError(resp.status_code, _error_detail(resp))
|
|
211
|
+
return _unwrap(resp.json())
|
|
212
|
+
|
|
213
|
+
async def me(self) -> dict[str, Any]:
|
|
214
|
+
return await self.request("GET", "/auth/me")
|
|
215
|
+
|
|
216
|
+
async def logout(self) -> dict[str, Any]:
|
|
217
|
+
return await self.request("POST", "/auth/logout")
|
|
218
|
+
|
|
219
|
+
async def upload_attachment(self, path: Path, *, _retried: bool = False) -> dict[str, Any]:
|
|
220
|
+
"""Upload a CLI attachment without base64 expansion.
|
|
221
|
+
|
|
222
|
+
Multipart keeps images and other binary inputs byte-for-byte and is
|
|
223
|
+
the same authenticated endpoint used by the web composer.
|
|
224
|
+
"""
|
|
225
|
+
path = path.expanduser().resolve()
|
|
226
|
+
content_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
|
|
227
|
+
url = f"{_api_root(self.config.api_base)}/files/upload"
|
|
228
|
+
with path.open("rb") as handle:
|
|
229
|
+
response = await self._client.post(
|
|
230
|
+
url,
|
|
231
|
+
files={"file": (path.name, handle, content_type)},
|
|
232
|
+
data={"source": "tamfis-code"},
|
|
233
|
+
headers=self._headers(),
|
|
234
|
+
)
|
|
235
|
+
if response.status_code == 401 and not _retried:
|
|
236
|
+
if await self._refresh():
|
|
237
|
+
return await self.upload_attachment(path, _retried=True)
|
|
238
|
+
raise AuthRequiredError(401, "Not authenticated -- run `tamfis-code login`")
|
|
239
|
+
if response.status_code >= 400:
|
|
240
|
+
raise RemoteAPIError(response.status_code, _error_detail(response))
|
|
241
|
+
return _unwrap(response.json())
|
|
242
|
+
|
|
243
|
+
# -- servers ----------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
async def list_servers(self) -> list[dict[str, Any]]:
|
|
246
|
+
return await self.request("GET", "/remote/servers")
|
|
247
|
+
|
|
248
|
+
async def register_local_server(self, name: str) -> dict[str, Any]:
|
|
249
|
+
return await self.request(
|
|
250
|
+
"POST", "/remote/servers", json_body={"name": name, "transport_type": "local"}
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
# -- sessions -----------------------------------------------------
|
|
254
|
+
|
|
255
|
+
async def list_sessions(self) -> list[dict[str, Any]]:
|
|
256
|
+
return await self.request("GET", "/remote/sessions")
|
|
257
|
+
|
|
258
|
+
async def create_session(self, server_id: int, working_directory: Optional[str] = None) -> dict[str, Any]:
|
|
259
|
+
body: dict[str, Any] = {"server_id": server_id}
|
|
260
|
+
if working_directory:
|
|
261
|
+
body["working_directory"] = working_directory
|
|
262
|
+
return await self.request("POST", "/remote/sessions", json_body=body)
|
|
263
|
+
|
|
264
|
+
async def get_session(self, session_id: int) -> dict[str, Any]:
|
|
265
|
+
return await self.request("GET", f"/remote/sessions/{session_id}")
|
|
266
|
+
|
|
267
|
+
async def set_session_cwd(self, session_id: int, working_directory: str) -> dict[str, Any]:
|
|
268
|
+
return await self.request(
|
|
269
|
+
"POST", f"/remote/sessions/{session_id}/cwd", json_body={"working_directory": working_directory}
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
async def expand_session_workspace(self, session_id: int, path: str) -> dict[str, Any]:
|
|
273
|
+
return await self.request(
|
|
274
|
+
"POST", f"/remote/sessions/{session_id}/expand-workspace", json_body={"path": path}
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
async def get_thread(self, session_id: int, after_sequence: int = 0) -> dict[str, Any]:
|
|
278
|
+
return await self.request(
|
|
279
|
+
"GET", f"/remote/sessions/{session_id}/thread", params={"after_sequence": after_sequence}
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
# -- file mutation ledger (Phase 14: diff/rollback) ------------------
|
|
283
|
+
|
|
284
|
+
async def list_file_mutations(self, session_id: int, limit: int = 50) -> dict[str, Any]:
|
|
285
|
+
return await self.request(
|
|
286
|
+
"GET", f"/remote/sessions/{session_id}/file-mutations", params={"limit": limit}
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
async def revert_file_mutation(self, session_id: int, mutation_id: str) -> dict[str, Any]:
|
|
290
|
+
return await self.request(
|
|
291
|
+
"POST", f"/remote/sessions/{session_id}/file-mutations/{mutation_id}/revert"
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
# -- commands (explicit shell path) --------------------------------
|
|
295
|
+
|
|
296
|
+
async def submit_command(self, session_id: int, command: str) -> dict[str, Any]:
|
|
297
|
+
return await self.request(
|
|
298
|
+
"POST", f"/remote/sessions/{session_id}/commands", json_body={"command": command}
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
async def get_command(self, command_id: int) -> dict[str, Any]:
|
|
302
|
+
return await self.request("GET", f"/remote/commands/{command_id}")
|
|
303
|
+
|
|
304
|
+
async def approve_command(self, command_id: int, decision: str) -> dict[str, Any]:
|
|
305
|
+
return await self.request(
|
|
306
|
+
"POST", f"/remote/commands/{command_id}/approve", json_body={"decision": decision}
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
async def cancel_command(self, command_id: int) -> dict[str, Any]:
|
|
310
|
+
return await self.request("POST", f"/remote/commands/{command_id}/cancel")
|
|
311
|
+
|
|
312
|
+
# -- persistent background PTY terminal --------------------------------
|
|
313
|
+
|
|
314
|
+
async def start_pty(self, session_id: int, shell_command: str = "bash", cols: int = 80, rows: int = 24) -> dict[str, Any]:
|
|
315
|
+
return await self.request(
|
|
316
|
+
"POST", "/remote/pty/start",
|
|
317
|
+
json_body={"session_id": session_id, "shell_command": shell_command, "cols": cols, "rows": rows},
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
async def approve_pty(self, pty_id: str) -> dict[str, Any]:
|
|
321
|
+
return await self.request("POST", f"/remote/pty/{pty_id}/approve")
|
|
322
|
+
|
|
323
|
+
async def deny_pty(self, pty_id: str) -> dict[str, Any]:
|
|
324
|
+
return await self.request("POST", f"/remote/pty/{pty_id}/deny")
|
|
325
|
+
|
|
326
|
+
async def write_pty(self, pty_id: str, data: str) -> dict[str, Any]:
|
|
327
|
+
return await self.request("POST", f"/remote/pty/{pty_id}/write", json_body={"data": data})
|
|
328
|
+
|
|
329
|
+
async def read_pty(self, pty_id: str, since: int = 0) -> dict[str, Any]:
|
|
330
|
+
return await self.request("GET", f"/remote/pty/{pty_id}/read", params={"since": since})
|
|
331
|
+
|
|
332
|
+
async def kill_pty(self, pty_id: str) -> dict[str, Any]:
|
|
333
|
+
return await self.request("POST", f"/remote/pty/{pty_id}/kill")
|
|
334
|
+
|
|
335
|
+
async def list_pty(self, session_id: int) -> list[dict[str, Any]]:
|
|
336
|
+
return await self.request("GET", f"/remote/sessions/{session_id}/pty")
|
|
337
|
+
|
|
338
|
+
# -- AI tasks ---------------------------------------------------------
|
|
339
|
+
|
|
340
|
+
async def run_ai_task(
|
|
341
|
+
self, session_id: int, objective: str, mode: str = "coding", task_id: Optional[str] = None,
|
|
342
|
+
model: str = "auto", provider: Optional[str] = None,
|
|
343
|
+
attachments: Optional[list[dict[str, Any]]] = None,
|
|
344
|
+
) -> dict[str, Any]:
|
|
345
|
+
# Identify CLI-originated work so the shared Remote service can
|
|
346
|
+
# apply tamfis-code preferences without changing web/chat routing.
|
|
347
|
+
body: dict[str, Any] = {
|
|
348
|
+
"objective": objective,
|
|
349
|
+
"mode": mode,
|
|
350
|
+
"source_client": "tamfis-code",
|
|
351
|
+
"model": model or "auto",
|
|
352
|
+
"attachments": attachments or [],
|
|
353
|
+
}
|
|
354
|
+
if provider:
|
|
355
|
+
body["provider"] = provider
|
|
356
|
+
if task_id:
|
|
357
|
+
body["task_id"] = task_id
|
|
358
|
+
return await self.request("POST", f"/remote/sessions/{session_id}/ai-tasks", json_body=body)
|
|
359
|
+
|
|
360
|
+
async def list_models(self, provider: Optional[str] = None) -> dict[str, Any]:
|
|
361
|
+
params: dict[str, Any] = {"per_page": 200}
|
|
362
|
+
if provider:
|
|
363
|
+
params["provider"] = provider
|
|
364
|
+
return await self.request("GET", "/models", params=params)
|
|
365
|
+
|
|
366
|
+
async def get_task(self, task_id: str) -> dict[str, Any]:
|
|
367
|
+
return await self.request("GET", f"/remote/tasks/{task_id}")
|
|
368
|
+
|
|
369
|
+
async def cancel_task(self, task_id: str) -> dict[str, Any]:
|
|
370
|
+
return await self.request("POST", f"/remote/tasks/{task_id}/cancel")
|
|
371
|
+
|
|
372
|
+
async def retry_task(self, task_id: str, mode: Optional[str] = None) -> dict[str, Any]:
|
|
373
|
+
body = {"mode": mode} if mode else {}
|
|
374
|
+
return await self.request("POST", f"/remote/tasks/{task_id}/retry", json_body=body)
|
|
375
|
+
|
|
376
|
+
async def add_task_instruction(self, task_id: str, text: str, classification: str = "append") -> dict[str, Any]:
|
|
377
|
+
return await self.request(
|
|
378
|
+
"POST", f"/remote/tasks/{task_id}/instructions",
|
|
379
|
+
json_body={"text": text, "classification": classification},
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
# -- streaming ---------------------------------------------------------
|
|
383
|
+
|
|
384
|
+
async def stream_session(self, session_id: int, last_event_id: int = 0) -> AsyncIterator[dict[str, Any]]:
|
|
385
|
+
"""Yields parsed event envelopes from GET /sessions/{id}/stream.
|
|
386
|
+
|
|
387
|
+
Mirrors remoteStore.ts's connectSessionStream: an authenticated raw
|
|
388
|
+
fetch/stream (not EventSource, which cannot send an Authorization
|
|
389
|
+
header), reconnecting the caller's responsibility -- this generator
|
|
390
|
+
ends when the server closes the stream or on a connection error, and
|
|
391
|
+
does not retry itself, so `interactive.py`/non-interactive callers
|
|
392
|
+
control reconnect-with-last-event-id explicitly."""
|
|
393
|
+
url = f"{_api_root(self.config.api_base)}/remote/sessions/{session_id}/stream"
|
|
394
|
+
params = {"last_event_id": max(0, last_event_id)}
|
|
395
|
+
async with self._client.stream(
|
|
396
|
+
"GET", url, params=params, headers=self._headers(), timeout=None
|
|
397
|
+
) as resp:
|
|
398
|
+
if resp.status_code == 401:
|
|
399
|
+
if await self._refresh():
|
|
400
|
+
async for event in self.stream_session(session_id, last_event_id):
|
|
401
|
+
yield event
|
|
402
|
+
return
|
|
403
|
+
raise AuthRequiredError(401, "Not authenticated -- run `tamfis-code login`")
|
|
404
|
+
if resp.status_code >= 400:
|
|
405
|
+
body = await resp.aread()
|
|
406
|
+
raise RemoteAPIError(resp.status_code, body.decode("utf-8", errors="replace"))
|
|
407
|
+
|
|
408
|
+
event_type = None
|
|
409
|
+
event_id = None
|
|
410
|
+
data_lines: list[str] = []
|
|
411
|
+
async for raw_line in resp.aiter_lines():
|
|
412
|
+
line = raw_line.rstrip("\n")
|
|
413
|
+
if line == "":
|
|
414
|
+
if data_lines:
|
|
415
|
+
raw_data = "\n".join(data_lines)
|
|
416
|
+
data_lines = []
|
|
417
|
+
if raw_data.strip() and raw_data.strip() != "[DONE]":
|
|
418
|
+
try:
|
|
419
|
+
payload = json.loads(raw_data)
|
|
420
|
+
except json.JSONDecodeError:
|
|
421
|
+
continue
|
|
422
|
+
if event_type and "event_type" not in payload:
|
|
423
|
+
payload["event_type"] = event_type
|
|
424
|
+
# Only the SSE `id:` field is a valid replay
|
|
425
|
+
# cursor. Canonical payloads also have a
|
|
426
|
+
# `sequence`, but that number belongs to a
|
|
427
|
+
# different/global event domain and must never
|
|
428
|
+
# advance a session stream checkpoint.
|
|
429
|
+
if event_id is not None:
|
|
430
|
+
try:
|
|
431
|
+
payload["stream_sequence"] = int(event_id)
|
|
432
|
+
payload.setdefault("sequence", int(event_id))
|
|
433
|
+
except ValueError:
|
|
434
|
+
payload["stream_event_id"] = event_id
|
|
435
|
+
yield payload
|
|
436
|
+
event_type = None
|
|
437
|
+
event_id = None
|
|
438
|
+
continue
|
|
439
|
+
if line.startswith("event:"):
|
|
440
|
+
event_type = line[len("event:"):].strip()
|
|
441
|
+
elif line.startswith("id:"):
|
|
442
|
+
event_id = line[len("id:"):].strip()
|
|
443
|
+
elif line.startswith("data:"):
|
|
444
|
+
data_lines.append(line[len("data:"):].strip())
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _unwrap(payload: Any) -> Any:
|
|
448
|
+
if isinstance(payload, dict) and "data" in payload and set(payload.keys()) <= {"data", "success", "message"}:
|
|
449
|
+
return payload["data"]
|
|
450
|
+
return payload
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _error_detail(resp: httpx.Response) -> str:
|
|
454
|
+
try:
|
|
455
|
+
data = resp.json()
|
|
456
|
+
except (json.JSONDecodeError, ValueError):
|
|
457
|
+
return resp.text or f"HTTP {resp.status_code}"
|
|
458
|
+
detail = data.get("detail") if isinstance(data, dict) else None
|
|
459
|
+
if isinstance(detail, list):
|
|
460
|
+
return "; ".join(str(item.get("msg", item)) for item in detail)
|
|
461
|
+
return str(detail or data)
|