agentlink-cli 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 (55) hide show
  1. agentlink_cli-0.1.0.dist-info/METADATA +136 -0
  2. agentlink_cli-0.1.0.dist-info/RECORD +55 -0
  3. agentlink_cli-0.1.0.dist-info/WHEEL +4 -0
  4. agentlink_cli-0.1.0.dist-info/entry_points.txt +3 -0
  5. connector/__init__.py +3 -0
  6. connector/acp/__init__.py +6 -0
  7. connector/acp/adapter.py +1221 -0
  8. connector/acp/config_options.py +175 -0
  9. connector/acp/discovery.py +385 -0
  10. connector/acp/manifest.py +110 -0
  11. connector/acp/manifests/__init__.py +1 -0
  12. connector/acp/manifests/codebuddy.json +37 -0
  13. connector/acp/manifests/cursor.json +39 -0
  14. connector/acp/manifests/gemini.json +33 -0
  15. connector/acp/manifests/grok_build.json +31 -0
  16. connector/acp/reducer.py +615 -0
  17. connector/acp/rpc.py +308 -0
  18. connector/adapter.py +39 -0
  19. connector/attachments.py +36 -0
  20. connector/capabilities.py +603 -0
  21. connector/claude/__init__.py +8 -0
  22. connector/claude/history_adapter.py +642 -0
  23. connector/claude/normalized.py +23 -0
  24. connector/claude/normalizers.py +97 -0
  25. connector/claude/path_utils.py +13 -0
  26. connector/claude/preferences.py +38 -0
  27. connector/claude/sdk_adapter.py +1376 -0
  28. connector/claude/timeline_identity.py +47 -0
  29. connector/claude/timeline_reducer.py +379 -0
  30. connector/claude/trust.py +69 -0
  31. connector/cli.py +280 -0
  32. connector/codex/__init__.py +3 -0
  33. connector/codex/adapter.py +1150 -0
  34. connector/codex/history.py +199 -0
  35. connector/codex/reducer.py +1309 -0
  36. connector/codex/rpc.py +261 -0
  37. connector/control.py +298 -0
  38. connector/json_rpc.py +143 -0
  39. connector/launch.py +310 -0
  40. connector/local/__init__.py +6 -0
  41. connector/local/common.py +118 -0
  42. connector/local/file_ops.py +144 -0
  43. connector/local/ops.py +92 -0
  44. connector/local/shell.py +225 -0
  45. connector/local/terminal.py +658 -0
  46. connector/local_ops.py +5 -0
  47. connector/local_runtime.py +139 -0
  48. connector/logging.py +50 -0
  49. connector/perf.py +89 -0
  50. connector/protocol.py +26 -0
  51. connector/registry.py +49 -0
  52. connector/runtime.py +1309 -0
  53. connector/sync_state.py +155 -0
  54. connector/time.py +7 -0
  55. connector/version.py +13 -0
@@ -0,0 +1,1221 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import secrets
5
+ import tempfile
6
+ import time
7
+ from collections.abc import Awaitable, Callable
8
+ from dataclasses import dataclass, field
9
+ from typing import Any
10
+
11
+ from connector.acp.config_options import (
12
+ extract_mode_options,
13
+ extract_model_options,
14
+ find_config_option,
15
+ is_interactive_auth_method,
16
+ order_headless_auth_method_ids,
17
+ order_interactive_auth_method_ids,
18
+ summarize_auth_methods,
19
+ )
20
+ from connector.acp.manifest import AgentManifest
21
+ from connector.acp.reducer import AcpTimelineReducer, map_approval_status_to_option
22
+ from connector.acp.rpc import AcpJsonRpcClient, AcpJsonRpcError
23
+ from connector.launch import LaunchTarget, launch_target
24
+ from connector.logging import logger
25
+ from connector.perf import StageTimer, elapsed_ms, log_stage
26
+ from connector.time import utc_now
27
+ from connector.version import connector_version
28
+
29
+ NotificationSink = Callable[[str, dict[str, Any]], Awaitable[None]] | None
30
+ AttachmentDownloader = Callable[[str, str], Awaitable[tuple[bytes, str, str]]]
31
+
32
+ # Default ceiling for a single prompt turn (can be overridden via manifest.quirks).
33
+ _DEFAULT_MAX_TURN_SECONDS = 60 * 60
34
+ # User-triggered interactive OAuth (browser) — allow several minutes to complete.
35
+ _INTERACTIVE_AUTH_TIMEOUT_S = 5 * 60
36
+ # Bound session/new: fail fast instead of hanging near UI/RPC ceilings.
37
+ _SESSION_NEW_TIMEOUT_S = 20.0
38
+ _AUTH_ERROR_TOKENS = (
39
+ "auth",
40
+ "login",
41
+ "unauthor",
42
+ "api key",
43
+ "credential",
44
+ "not configured",
45
+ "token",
46
+ )
47
+
48
+
49
+ @dataclass(slots=True)
50
+ class _PendingApproval:
51
+ request_id: str | int
52
+ future: asyncio.Future[str]
53
+ options: list[dict[str, Any]]
54
+
55
+
56
+ @dataclass(slots=True)
57
+ class _SessionRuntime:
58
+ session_id: str
59
+ cwd: str | None = None
60
+ external_session_id: str | None = None
61
+ active_turn_id: str | None = None
62
+ active_task: asyncio.Task[None] | None = None
63
+ interrupted: bool = False
64
+ client_message_id: str | None = None
65
+ reducer: AcpTimelineReducer | None = None
66
+ pending_approvals: dict[str, _PendingApproval] = field(default_factory=dict)
67
+ # ACP config options from session/new (model/mode selectors).
68
+ config_options: list[dict[str, Any]] = field(default_factory=list)
69
+ # Short lock only for claiming/releasing a turn (not held for the whole turn).
70
+ claim_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
71
+ turn_timer: StageTimer | None = None
72
+
73
+
74
+ @dataclass(slots=True)
75
+ class AcpAdapter:
76
+ """Generic Adapter backed by an ACP agent subprocess."""
77
+
78
+ manifest: AgentManifest
79
+ notification_sink: NotificationSink = None
80
+ attachment_downloader: AttachmentDownloader | None = None
81
+ launch: LaunchTarget | None = None
82
+ client_factory: Callable[[list[str], dict[str, str] | None, str | None], AcpJsonRpcClient] | None = None
83
+ _client: AcpJsonRpcClient | None = field(default=None, init=False, repr=False)
84
+ _initialized: bool = field(default=False, init=False, repr=False)
85
+ _needs_restart: bool = field(default=False, init=False, repr=False)
86
+ _agent_capabilities: dict[str, Any] = field(default_factory=dict, init=False, repr=False)
87
+ _auth_methods: list[dict[str, Any]] = field(default_factory=list, init=False, repr=False)
88
+ _sessions: dict[str, _SessionRuntime] = field(default_factory=dict, init=False, repr=False)
89
+ _start_lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False, repr=False)
90
+ _auth_status: str = field(default="unknown", init=False, repr=False)
91
+ _auth_hint: str | None = field(default=None, init=False, repr=False)
92
+ # external_session_id -> last known ACP configOptions from session/new|set_config
93
+
94
+ @property
95
+ def runtime(self) -> str:
96
+ return self.manifest.id
97
+
98
+ def rewire(self, target: LaunchTarget | None) -> None:
99
+ """Point at a new binary. Old process is closed on next ensure_client."""
100
+ self.launch = target
101
+ self._initialized = False
102
+ self._needs_restart = True
103
+
104
+ def forget_sync_state(self) -> None:
105
+ return None
106
+
107
+ def forget_persisted_sync_state(self, connector_id: str) -> None:
108
+ self.forget_sync_state()
109
+
110
+ async def create_session(self, params: dict[str, Any]) -> dict[str, Any]:
111
+ session_id = _optional_string(params.get("sessionId")) or f"sess_{self.runtime}_{secrets.token_urlsafe(10)}"
112
+ cwd = _require_cwd(params)
113
+ runtime = self._runtime_for(session_id, params)
114
+ timer = StageTimer()
115
+ # Skip spawn/session/new when a prior probe already proved auth is missing.
116
+ self._raise_if_auth_blocked()
117
+ client = await self._ensure_client()
118
+ try:
119
+ # Bound cold session/new (Cursor/Grok historically hung well past UI timeouts).
120
+ result = await client.request(
121
+ "session/new",
122
+ {
123
+ "cwd": cwd,
124
+ "mcpServers": params.get("mcpServers") if isinstance(params.get("mcpServers"), list) else [],
125
+ },
126
+ timeout=_SESSION_NEW_TIMEOUT_S,
127
+ )
128
+ except (AcpJsonRpcError, TimeoutError, asyncio.TimeoutError) as exc:
129
+ stderr = client.stderr_excerpt if client else ""
130
+ detail = str(exc)
131
+ if _is_authish_error(detail):
132
+ detail = await self._mark_auth_required(detail)
133
+ if stderr:
134
+ detail = f"{detail}; stderr={stderr[:800]}"
135
+ if isinstance(exc, (TimeoutError, asyncio.TimeoutError)):
136
+ detail = (
137
+ f"{self.runtime} session/new timed out after {_SESSION_NEW_TIMEOUT_S:.0f}s: {detail}"
138
+ )
139
+ raise AcpJsonRpcError(detail) from exc
140
+ external = _optional_string(result.get("sessionId")) or _optional_string(result.get("session_id"))
141
+ if not external:
142
+ raise AcpJsonRpcError(f"{self.runtime} session/new did not return sessionId")
143
+ self._set_auth_status("ok")
144
+ runtime.external_session_id = external
145
+ runtime.cwd = cwd
146
+ runtime.reducer = AcpTimelineReducer(runtime=self.runtime)
147
+ config_options = result.get("configOptions") if isinstance(result.get("configOptions"), list) else []
148
+ runtime.config_options = [opt for opt in config_options if isinstance(opt, dict)]
149
+ # Apply preferred model/mode from AA settings if agent exposed config options.
150
+ await self._apply_session_settings(client, runtime, params)
151
+ model_options = extract_model_options(runtime.config_options)
152
+ mode_options = extract_mode_options(runtime.config_options)
153
+ elapsed = timer.elapsed_ms()
154
+ log_stage(
155
+ "adapter.create_session",
156
+ elapsed,
157
+ runtime=self.runtime,
158
+ session_id=session_id,
159
+ )
160
+ logger.info(
161
+ "{} session created session_id={} external={} elapsed_ms={}",
162
+ self.runtime,
163
+ session_id,
164
+ external,
165
+ elapsed,
166
+ )
167
+ await self._emit(
168
+ "session.updated",
169
+ {
170
+ "sessionId": session_id,
171
+ "runtime": self.runtime,
172
+ "externalSessionId": external,
173
+ "status": "idle",
174
+ "cwd": cwd,
175
+ "sourceObservedAt": utc_now(),
176
+ "configOptions": runtime.config_options,
177
+ "modelOptions": model_options,
178
+ "modeOptions": mode_options,
179
+ },
180
+ )
181
+ # Also refresh device-level observed options for schema merge.
182
+ if model_options or mode_options or runtime.config_options:
183
+ await self._emit(
184
+ "runtime.optionsUpdated",
185
+ {
186
+ "runtime": self.runtime,
187
+ "configOptions": runtime.config_options,
188
+ "modelOptions": model_options,
189
+ "modeOptions": mode_options,
190
+ "authStatus": "ok",
191
+ "sourceObservedAt": utc_now(),
192
+ },
193
+ )
194
+ return {
195
+ "sessionId": session_id,
196
+ "externalSessionId": external,
197
+ "configOptions": runtime.config_options,
198
+ "modelOptions": model_options,
199
+ "modeOptions": mode_options,
200
+ "backendNotifications": [],
201
+ }
202
+
203
+ async def sync_session(self, params: dict[str, Any]) -> dict[str, Any]:
204
+ session_id = _required(params, "sessionId")
205
+ runtime = self._sessions.get(session_id)
206
+ external = _optional_string(params.get("externalSessionId")) or (
207
+ runtime.external_session_id if runtime else None
208
+ )
209
+ if external is None:
210
+ return {"backendNotifications": []}
211
+ caps = self._agent_capabilities
212
+ session_caps = caps.get("sessionCapabilities") if isinstance(caps.get("sessionCapabilities"), dict) else {}
213
+ load_supported = bool(caps.get("loadSession")) or "load" in session_caps or "resume" in session_caps
214
+ if not load_supported:
215
+ return {"backendNotifications": []}
216
+ cwd = (
217
+ _optional_string(params.get("cwd"))
218
+ or (runtime.cwd if runtime else None)
219
+ )
220
+ if not cwd:
221
+ raise ValueError("missing cwd")
222
+ client = await self._ensure_client()
223
+ method = "session/load" if bool(caps.get("loadSession")) or "load" in session_caps else "session/resume"
224
+ try:
225
+ await client.request(
226
+ method,
227
+ {
228
+ "sessionId": external,
229
+ "cwd": cwd,
230
+ "mcpServers": [],
231
+ },
232
+ )
233
+ except AcpJsonRpcError as exc:
234
+ logger.warning("{} {} failed: {}", self.runtime, method, exc)
235
+ return {"backendNotifications": []}
236
+
237
+ async def sync_existing_sessions(
238
+ self,
239
+ connector_id: str,
240
+ *,
241
+ limit: int = 100,
242
+ force: bool = False,
243
+ notification_sink: Callable[[list[dict[str, Any]]], Awaitable[None]] | None = None,
244
+ ) -> dict[str, Any]:
245
+ del connector_id, force, notification_sink
246
+ caps = self._agent_capabilities
247
+ session_caps = caps.get("sessionCapabilities") if isinstance(caps.get("sessionCapabilities"), dict) else {}
248
+ if "list" not in session_caps and not caps.get("sessionList"):
249
+ return {"threads": [], "skippedThreads": [], "backendNotifications": []}
250
+ try:
251
+ client = await self._ensure_client()
252
+ result = await client.request("session/list", {"limit": limit})
253
+ sessions = result.get("sessions") if isinstance(result.get("sessions"), list) else []
254
+ threads = [
255
+ str(item.get("sessionId"))
256
+ for item in sessions
257
+ if isinstance(item, dict) and item.get("sessionId")
258
+ ]
259
+ return {"threads": threads[:limit], "skippedThreads": [], "backendNotifications": []}
260
+ except Exception:
261
+ logger.exception("{} session/list failed", self.runtime)
262
+ return {"threads": [], "skippedThreads": [], "backendNotifications": []}
263
+
264
+ async def start_turn(self, params: dict[str, Any]) -> dict[str, Any]:
265
+ sync_timer = StageTimer()
266
+ session_id = _required(params, "sessionId")
267
+ content = _required(params, "content")
268
+ runtime = self._runtime_for(session_id, params)
269
+ if runtime.external_session_id is None:
270
+ created = await self.create_session(params)
271
+ runtime.external_session_id = created.get("externalSessionId")
272
+
273
+ async with runtime.claim_lock:
274
+ if runtime.active_task is not None and not runtime.active_task.done():
275
+ raise AcpJsonRpcError(f"{self.runtime} turn already running for this session")
276
+ runtime.interrupted = False
277
+ turn_id = _optional_string(params.get("turnId")) or f"turn_{self.runtime}_{secrets.token_urlsafe(8)}"
278
+ runtime.active_turn_id = turn_id
279
+ runtime.client_message_id = _optional_string(params.get("clientMessageId"))
280
+ if runtime.reducer is None:
281
+ runtime.reducer = AcpTimelineReducer(runtime=self.runtime)
282
+ runtime.turn_timer = StageTimer()
283
+ runtime.active_task = asyncio.create_task(
284
+ self._drive_turn(runtime=runtime, params=params, content=content, turn_id=turn_id)
285
+ )
286
+ log_stage(
287
+ "adapter.start_turn_sync",
288
+ sync_timer.elapsed_ms(),
289
+ runtime=self.runtime,
290
+ session_id=session_id,
291
+ turn_id=turn_id,
292
+ )
293
+ return {"turnId": turn_id}
294
+
295
+ async def interrupt_turn(self, params: dict[str, Any]) -> dict[str, Any]:
296
+ session_id = _required(params, "sessionId")
297
+ runtime = self._sessions.get(session_id)
298
+ if runtime is None or runtime.external_session_id is None:
299
+ return {"interrupted": False, "reason": "session not registered"}
300
+ runtime.interrupted = True
301
+ for pending in list(runtime.pending_approvals.values()):
302
+ if not pending.future.done():
303
+ pending.future.set_result("cancelled")
304
+ try:
305
+ client = await self._ensure_client()
306
+ await client.notify("session/cancel", {"sessionId": runtime.external_session_id})
307
+ return {"interrupted": True}
308
+ except Exception as exc:
309
+ logger.exception("{} session/cancel failed", self.runtime)
310
+ return {"interrupted": False, "reason": str(exc)}
311
+
312
+ async def resolve_approval(self, params: dict[str, Any]) -> dict[str, Any]:
313
+ session_id = _required(params, "sessionId")
314
+ approval_id = _required(params, "approvalId")
315
+ status = _required(params, "status")
316
+ runtime = self._sessions.get(session_id)
317
+ if runtime is None:
318
+ return {"resolved": False, "reason": "session not registered"}
319
+ pending = runtime.pending_approvals.get(approval_id)
320
+ if pending is None:
321
+ for key, value in runtime.pending_approvals.items():
322
+ if str(value.request_id) == str(approval_id):
323
+ pending = value
324
+ approval_id = key
325
+ break
326
+ if pending is None:
327
+ return {"resolved": False, "reason": "approval not pending"}
328
+ if not pending.future.done():
329
+ pending.future.set_result(status)
330
+ return {"resolved": True}
331
+
332
+ async def close(self) -> None:
333
+ await self._shutdown_client()
334
+
335
+ def _runtime_for(self, session_id: str, params: dict[str, Any]) -> _SessionRuntime:
336
+ runtime = self._sessions.get(session_id)
337
+ if runtime is None:
338
+ runtime = _SessionRuntime(
339
+ session_id=session_id,
340
+ cwd=_optional_string(params.get("cwd")),
341
+ external_session_id=_optional_string(params.get("externalSessionId")),
342
+ reducer=AcpTimelineReducer(runtime=self.runtime),
343
+ )
344
+ self._sessions[session_id] = runtime
345
+ if params.get("cwd"):
346
+ runtime.cwd = _optional_string(params.get("cwd"))
347
+ if params.get("externalSessionId"):
348
+ runtime.external_session_id = _optional_string(params.get("externalSessionId"))
349
+ return runtime
350
+
351
+ async def _drive_turn(
352
+ self,
353
+ *,
354
+ runtime: _SessionRuntime,
355
+ params: dict[str, Any],
356
+ content: str,
357
+ turn_id: str,
358
+ ) -> None:
359
+ assert runtime.reducer is not None
360
+ max_turn_s = _max_turn_seconds(self.manifest)
361
+ outcome = "done"
362
+ try:
363
+ reduced = runtime.reducer.turn_start(
364
+ session_id=runtime.session_id,
365
+ turn_id=turn_id,
366
+ external_session_id=runtime.external_session_id,
367
+ content=content,
368
+ client_message_id=runtime.client_message_id,
369
+ attachments=_attachments_metadata(params),
370
+ )
371
+ await self._emit_reduction(reduced)
372
+ client = await self._ensure_client()
373
+ # Re-apply model/settings before each turn (composer can change mid-session).
374
+ await self._apply_session_settings(client, runtime, params)
375
+ prompt = await self._build_prompt(content=content, params=params, runtime=runtime)
376
+ try:
377
+ result = await client.request(
378
+ "session/prompt",
379
+ {
380
+ "sessionId": runtime.external_session_id,
381
+ "prompt": prompt,
382
+ },
383
+ timeout=max_turn_s,
384
+ )
385
+ except AcpJsonRpcError as exc:
386
+ if runtime.interrupted:
387
+ outcome = "cancelled"
388
+ end = runtime.reducer.turn_end(
389
+ session_id=runtime.session_id,
390
+ turn_id=turn_id,
391
+ external_session_id=runtime.external_session_id,
392
+ stop_reason="cancelled",
393
+ interrupted=True,
394
+ )
395
+ await self._emit_reduction(end)
396
+ return
397
+ raise exc
398
+ stop_reason = _optional_string(result.get("stopReason") or result.get("stop_reason"))
399
+ if runtime.interrupted:
400
+ outcome = "cancelled"
401
+ end = runtime.reducer.turn_end(
402
+ session_id=runtime.session_id,
403
+ turn_id=turn_id,
404
+ external_session_id=runtime.external_session_id,
405
+ stop_reason=stop_reason,
406
+ interrupted=runtime.interrupted,
407
+ )
408
+ await self._emit_reduction(end)
409
+ except asyncio.CancelledError:
410
+ outcome = "cancelled"
411
+ raise
412
+ except Exception as exc:
413
+ outcome = "cancelled" if runtime.interrupted else "failed"
414
+ logger.exception(
415
+ "ACP turn failed runtime={} session_id={} turn_id={}",
416
+ self.runtime,
417
+ runtime.session_id,
418
+ turn_id,
419
+ )
420
+ stderr = self._client.stderr_excerpt if self._client else ""
421
+ if runtime.reducer is not None:
422
+ # Always go through reducer so contentHash/revision stay consistent.
423
+ end = runtime.reducer.turn_end(
424
+ session_id=runtime.session_id,
425
+ turn_id=turn_id,
426
+ external_session_id=runtime.external_session_id,
427
+ stop_reason=(str(exc)[:500] or "failed"),
428
+ interrupted=runtime.interrupted,
429
+ )
430
+ await self._emit_reduction(end)
431
+ await self._emit(
432
+ "runtime.error",
433
+ {
434
+ "sessionId": runtime.session_id,
435
+ "runtime": self.runtime,
436
+ "message": str(exc),
437
+ "stderr": stderr or None,
438
+ },
439
+ )
440
+ finally:
441
+ timer = runtime.turn_timer
442
+ if timer is not None:
443
+ timer.mark_turn_complete(
444
+ outcome=outcome,
445
+ runtime=self.runtime,
446
+ session_id=runtime.session_id,
447
+ turn_id=turn_id,
448
+ )
449
+ runtime.turn_timer = None
450
+ async with runtime.claim_lock:
451
+ if runtime.active_turn_id == turn_id:
452
+ runtime.active_turn_id = None
453
+ runtime.active_task = None
454
+ runtime.client_message_id = None
455
+ runtime.pending_approvals.clear()
456
+
457
+ async def _build_prompt(
458
+ self,
459
+ *,
460
+ content: str,
461
+ params: dict[str, Any],
462
+ runtime: _SessionRuntime,
463
+ ) -> list[dict[str, Any]]:
464
+ blocks: list[dict[str, Any]] = [{"type": "text", "text": content}]
465
+ attachments = params.get("attachments")
466
+ if not isinstance(attachments, list) or self.attachment_downloader is None:
467
+ return blocks
468
+ for entry in attachments:
469
+ if not isinstance(entry, dict):
470
+ continue
471
+ file_id = entry.get("fileId") or entry.get("id")
472
+ if not isinstance(file_id, str) or not file_id:
473
+ continue
474
+ media_type = str(entry.get("mediaType") or entry.get("media_type") or "")
475
+ name = str(entry.get("name") or file_id)
476
+ try:
477
+ data, original_name, detected_type = await self.attachment_downloader(
478
+ runtime.session_id, file_id
479
+ )
480
+ except Exception:
481
+ logger.exception("attachment download failed file_id={}", file_id)
482
+ blocks.append({"type": "text", "text": f"\n\nAttached file: {name}"})
483
+ continue
484
+ media = media_type or detected_type or "application/octet-stream"
485
+ if media.startswith("image/"):
486
+ import base64
487
+
488
+ blocks.append(
489
+ {
490
+ "type": "image",
491
+ "mimeType": media,
492
+ "data": base64.b64encode(data).decode("ascii"),
493
+ }
494
+ )
495
+ else:
496
+ blocks.append(
497
+ {
498
+ "type": "text",
499
+ "text": f"\n\nAttached file: {original_name or name} ({media})",
500
+ }
501
+ )
502
+ return blocks
503
+
504
+ async def _ensure_client(self, *, skip_auto_auth: bool = False) -> AcpJsonRpcClient:
505
+ async with self._start_lock:
506
+ if (
507
+ not self._needs_restart
508
+ and self._client is not None
509
+ and self._initialized
510
+ and self._client.alive
511
+ ):
512
+ return self._client
513
+ await self._shutdown_client_unlocked()
514
+ command = self._command()
515
+ started = time.perf_counter()
516
+ # Process cwd is launch context only (None → inherit connector cwd).
517
+ # Per-session workspace is always passed via session/new|load cwd.
518
+ factory = self.client_factory or (
519
+ lambda cmd, env, workdir: AcpJsonRpcClient(cmd, env=env, cwd=workdir)
520
+ )
521
+ client = factory(command, dict(self.manifest.env) or None, None)
522
+ await client.start(
523
+ notification_handler=self._on_notification,
524
+ server_request_handler=self._on_server_request,
525
+ exit_handler=self._on_client_exit,
526
+ )
527
+ try:
528
+ # Cap initialize: cold agent spawn (esp. Cursor via shell shims)
529
+ # previously hung for the default 120s and blocked UI.
530
+ init = await client.request(
531
+ "initialize",
532
+ {
533
+ "protocolVersion": 1,
534
+ "clientCapabilities": {
535
+ **self.manifest.client_capabilities(),
536
+ "session": {"configOptions": {"boolean": {}}},
537
+ },
538
+ "clientInfo": {
539
+ "name": "agent-link-connector",
540
+ "version": _connector_version(),
541
+ },
542
+ },
543
+ timeout=45.0,
544
+ )
545
+ except (AcpJsonRpcError, TimeoutError, asyncio.TimeoutError) as exc:
546
+ stderr = client.stderr_excerpt
547
+ await self._shutdown_client_unlocked()
548
+ detail = str(exc)
549
+ if stderr:
550
+ detail = f"{detail}; agent stderr: {stderr[:800]}"
551
+ raise AcpJsonRpcError(
552
+ f"failed to initialize {self.runtime} ACP agent: {detail}"
553
+ ) from exc
554
+ self._agent_capabilities = (
555
+ init.get("agentCapabilities")
556
+ if isinstance(init.get("agentCapabilities"), dict)
557
+ else {}
558
+ )
559
+ auth_methods = init.get("authMethods") if isinstance(init.get("authMethods"), list) else []
560
+ self._auth_methods = [m for m in auth_methods if isinstance(m, dict)]
561
+ if not skip_auto_auth:
562
+ await self._maybe_authenticate(client)
563
+ self._client = client
564
+ self._initialized = True
565
+ self._needs_restart = False
566
+ log_stage(
567
+ "adapter.ensure_client",
568
+ elapsed_ms(started),
569
+ runtime=self.runtime,
570
+ command=" ".join(str(part) for part in command[:6]),
571
+ )
572
+ return client
573
+
574
+ async def warm_start(self) -> None:
575
+ """Best-effort pre-spawn of the ACP process so first session is faster."""
576
+ try:
577
+ await self._ensure_client()
578
+ except Exception as exc:
579
+ logger.info("{} warm_start skipped: {}", self.runtime, exc)
580
+
581
+ async def _shutdown_client(self) -> None:
582
+ async with self._start_lock:
583
+ await self._shutdown_client_unlocked()
584
+
585
+ async def _shutdown_client_unlocked(self) -> None:
586
+ client = self._client
587
+ self._client = None
588
+ self._initialized = False
589
+ if client is not None:
590
+ try:
591
+ await client.close()
592
+ except Exception:
593
+ logger.exception("{} failed to close ACP client", self.runtime)
594
+
595
+ async def _on_client_exit(self) -> None:
596
+ """Called when the agent process stdout closes unexpectedly."""
597
+ logger.warning("{} ACP process exited", self.runtime)
598
+ self._needs_restart = True
599
+ self._initialized = False
600
+ # Fail any in-flight turns so UI does not stick on running.
601
+ for runtime in list(self._sessions.values()):
602
+ for pending in list(runtime.pending_approvals.values()):
603
+ if not pending.future.done():
604
+ pending.future.set_result("cancelled")
605
+ if runtime.active_turn_id and runtime.reducer is not None:
606
+ turn_id = runtime.active_turn_id
607
+ try:
608
+ end = runtime.reducer.turn_end(
609
+ session_id=runtime.session_id,
610
+ turn_id=turn_id,
611
+ external_session_id=runtime.external_session_id,
612
+ stop_reason="agent_process_exited",
613
+ interrupted=True,
614
+ )
615
+ await self._emit_reduction(end)
616
+ except Exception:
617
+ logger.exception("failed to finalize turn after process exit")
618
+ await self._emit(
619
+ "runtime.error",
620
+ {
621
+ "sessionId": runtime.session_id,
622
+ "runtime": self.runtime,
623
+ "message": "ACP agent process exited",
624
+ },
625
+ )
626
+
627
+ async def _apply_session_settings(
628
+ self,
629
+ client: AcpJsonRpcClient,
630
+ runtime: _SessionRuntime,
631
+ params: dict[str, Any],
632
+ ) -> None:
633
+ """Map AA model/permissionMode/effort onto ACP session/set_config_option when available."""
634
+ if not runtime.external_session_id:
635
+ return
636
+ model = _optional_string(params.get("model"))
637
+ permission_mode = _optional_string(params.get("permissionMode"))
638
+ effort = _optional_string(params.get("effort"))
639
+ if not model and not permission_mode and not effort:
640
+ return
641
+
642
+ options = runtime.config_options
643
+ model_opt = find_config_option(options, category="model", preferred_ids=("model", "llm", "models"))
644
+ mode_opt = find_config_option(
645
+ options, category="mode", preferred_ids=("mode", "permission", "permissionMode")
646
+ )
647
+ effort_opt = find_config_option(
648
+ options,
649
+ category="thought_level",
650
+ preferred_ids=("effort", "reasoning", "thought_level", "thinking"),
651
+ )
652
+ model_id = _optional_string(model_opt.get("id")) if model_opt else None
653
+ mode_id = _optional_string(mode_opt.get("id")) if mode_opt else None
654
+ effort_id = _optional_string(effort_opt.get("id")) if effort_opt else None
655
+ quirk_model = self.manifest.quirks.get("modelConfigId") if isinstance(self.manifest.quirks, dict) else None
656
+ if isinstance(quirk_model, str) and quirk_model:
657
+ model_id = model_id or quirk_model
658
+
659
+ async def _set(config_id: str | None, value: str | None) -> None:
660
+ if not config_id or not value:
661
+ return
662
+ try:
663
+ result = await client.request(
664
+ "session/set_config_option",
665
+ {
666
+ "sessionId": runtime.external_session_id,
667
+ "configId": config_id,
668
+ "value": value,
669
+ },
670
+ timeout=30.0,
671
+ )
672
+ updated = result.get("configOptions") if isinstance(result.get("configOptions"), list) else None
673
+ if updated is not None:
674
+ runtime.config_options = [opt for opt in updated if isinstance(opt, dict)]
675
+ except AcpJsonRpcError as exc:
676
+ # Fallback for Gemini-style unstable model API
677
+ if config_id == model_id:
678
+ try:
679
+ await client.request(
680
+ "session/set_model",
681
+ {"sessionId": runtime.external_session_id, "modelId": value},
682
+ timeout=30.0,
683
+ )
684
+ return
685
+ except AcpJsonRpcError:
686
+ pass
687
+ logger.warning(
688
+ "{} session/set_config_option id={} value={} failed: {}",
689
+ self.runtime,
690
+ config_id,
691
+ value,
692
+ exc,
693
+ )
694
+
695
+ await _set(model_id, model)
696
+ await _set(mode_id, permission_mode)
697
+ await _set(effort_id, effort)
698
+
699
+ # If agent never advertised configOptions, still try common methods once.
700
+ if not options and model:
701
+ for method, body in (
702
+ (
703
+ "session/set_config_option",
704
+ {
705
+ "sessionId": runtime.external_session_id,
706
+ "configId": "model",
707
+ "value": model,
708
+ },
709
+ ),
710
+ (
711
+ "session/set_model",
712
+ {"sessionId": runtime.external_session_id, "modelId": model},
713
+ ),
714
+ (
715
+ "unstable_setSessionModel",
716
+ {"sessionId": runtime.external_session_id, "modelId": model},
717
+ ),
718
+ ):
719
+ try:
720
+ await client.request(method, body, timeout=15.0)
721
+ break
722
+ except AcpJsonRpcError:
723
+ continue
724
+
725
+ async def authenticate_interactive(self, params: dict[str, Any] | None = None) -> dict[str, Any]:
726
+ """User-triggered ACP login (browser OAuth or headless re-check).
727
+
728
+ Never called from discovery/reconnect. First reuses local CLI credentials
729
+ via session/new; only then calls authenticate for the chosen method.
730
+ """
731
+ params = params if isinstance(params, dict) else {}
732
+ requested = _optional_string(params.get("methodId") or params.get("method_id"))
733
+ # Skip auto headless auth on ensure — we re-probe and auth intentionally below.
734
+ client = await self._ensure_client(skip_auto_auth=True)
735
+ method_ids = [
736
+ str(item.get("id") or item.get("methodId") or "")
737
+ for item in self._auth_methods
738
+ if (item.get("id") or item.get("methodId"))
739
+ ]
740
+ method_ids = [mid for mid in method_ids if mid]
741
+
742
+ # Many agents (Cursor/Gemini) already have disk credentials — session/new
743
+ # succeeds without calling authenticate (which often times out).
744
+ probe = await self._probe_auth_and_options(client)
745
+ if probe.get("authStatus") == "ok":
746
+ probe["methodId"] = requested or "local_session"
747
+ probe["reusedLocalCredentials"] = True
748
+ await self._emit_auth_status(probe)
749
+ return probe
750
+
751
+ if requested:
752
+ if requested not in method_ids:
753
+ raise AcpJsonRpcError(
754
+ f"{self.manifest.display_name} does not advertise auth method {requested!r}. "
755
+ f"Available: {', '.join(method_ids) or '(none)'}"
756
+ )
757
+ ordered = [requested]
758
+ else:
759
+ ordered = order_interactive_auth_method_ids(
760
+ method_ids,
761
+ preferred=list(self.manifest.preferred_auth_method_ids),
762
+ )
763
+ if not ordered:
764
+ ordered = order_headless_auth_method_ids(
765
+ method_ids,
766
+ preferred=list(self.manifest.preferred_auth_method_ids),
767
+ )
768
+ if not ordered:
769
+ await self._emit_auth_status(probe)
770
+ return probe
771
+
772
+ last_error: str | None = None
773
+ used_method: str | None = None
774
+ for mid in ordered:
775
+ # cursor_login / gemini-api-key can hang when re-prompting; use a longer
776
+ # budget only for explicit user-triggered sign-in.
777
+ timeout = (
778
+ _INTERACTIVE_AUTH_TIMEOUT_S
779
+ if is_interactive_auth_method(mid)
780
+ else 45.0
781
+ )
782
+ try:
783
+ logger.info(
784
+ "{} starting user-triggered authenticate method={} interactive={}",
785
+ self.runtime,
786
+ mid,
787
+ is_interactive_auth_method(mid),
788
+ )
789
+ await client.request(
790
+ "authenticate",
791
+ {"methodId": mid},
792
+ timeout=timeout,
793
+ )
794
+ used_method = mid
795
+ break
796
+ except Exception as exc:
797
+ last_error = str(exc)
798
+ logger.warning(
799
+ "{} interactive authenticate method={} failed: {}",
800
+ self.runtime,
801
+ mid,
802
+ exc,
803
+ )
804
+ continue
805
+
806
+ if used_method is None:
807
+ # Last chance: local credentials may still work without authenticate RPC.
808
+ probe = await self._probe_auth_and_options(client)
809
+ if probe.get("authStatus") == "ok":
810
+ probe["methodId"] = "local_session"
811
+ probe["reusedLocalCredentials"] = True
812
+ await self._emit_auth_status(probe)
813
+ return probe
814
+ methods = summarize_auth_methods(self._auth_methods)
815
+ names = ", ".join(m["name"] for m in methods) or "interactive login"
816
+ detail = (
817
+ f"Authentication failed for {self.manifest.display_name}. "
818
+ f"Tried: {', '.join(ordered)}. Methods: {names}."
819
+ )
820
+ if last_error:
821
+ detail = f"{detail} Last error: {last_error}"
822
+ if self.manifest.pre_auth_hint:
823
+ detail = f"{detail} Hint: {self.manifest.pre_auth_hint}"
824
+ raise AcpJsonRpcError(detail)
825
+
826
+ probe = await self._probe_auth_and_options(client)
827
+ probe["methodId"] = used_method
828
+ if probe.get("authStatus") != "ok":
829
+ logger.info(
830
+ "{} auth method={} returned but session still requires auth; restarting agent process",
831
+ self.runtime,
832
+ used_method,
833
+ )
834
+ self._needs_restart = True
835
+ client = await self._ensure_client(skip_auto_auth=True)
836
+ await self._maybe_authenticate(client)
837
+ probe = await self._probe_auth_and_options(client)
838
+ probe["methodId"] = used_method
839
+ probe["restarted"] = True
840
+
841
+ await self._emit_auth_status(probe)
842
+ return probe
843
+
844
+ def _raise_if_auth_blocked(self) -> None:
845
+ if self._auth_status != "required":
846
+ return
847
+ methods = summarize_auth_methods(self._auth_methods)
848
+ names = ", ".join(m["name"] for m in methods) or "interactive login"
849
+ detail = self._auth_hint or (
850
+ f"Authentication required for {self.manifest.display_name}. "
851
+ f"ACP auth methods: {names}. "
852
+ "Use Sign in in AgentLink to complete browser OAuth on this device "
853
+ "(interactive TUI login does not always satisfy headless ACP)."
854
+ )
855
+ raise AcpJsonRpcError(detail)
856
+
857
+ def _set_auth_status(self, status: str, hint: str | None = None) -> None:
858
+ self._auth_status = status
859
+ if status == "required":
860
+ self._auth_hint = hint or self._auth_hint
861
+ elif status == "ok":
862
+ self._auth_hint = None
863
+
864
+ async def _mark_auth_required(self, raw_detail: str) -> str:
865
+ methods = summarize_auth_methods(self._auth_methods)
866
+ names = ", ".join(m["name"] for m in methods) or "interactive login"
867
+ detail = (
868
+ f"Authentication required for {self.manifest.display_name}. "
869
+ f"ACP auth methods: {names}. "
870
+ "Use Sign in in AgentLink to complete browser OAuth on this device "
871
+ "(interactive TUI login does not always satisfy headless ACP)."
872
+ )
873
+ if self.manifest.pre_auth_hint:
874
+ detail = f"{detail} Hint: {self.manifest.pre_auth_hint}"
875
+ elif raw_detail and not _is_authish_error(raw_detail):
876
+ detail = f"{detail} ({raw_detail[:200]})"
877
+ self._set_auth_status("required", detail)
878
+ try:
879
+ await self._emit(
880
+ "runtime.optionsUpdated",
881
+ {
882
+ "runtime": self.runtime,
883
+ "authStatus": "required",
884
+ "authMethods": methods,
885
+ "authHint": detail,
886
+ "sourceObservedAt": utc_now(),
887
+ },
888
+ )
889
+ except Exception:
890
+ pass
891
+ return detail
892
+
893
+ async def _probe_auth_and_options(self, client: AcpJsonRpcClient) -> dict[str, Any]:
894
+ """session/new probe: authStatus + live model/mode options."""
895
+ methods = summarize_auth_methods(self._auth_methods)
896
+ probe: dict[str, Any] = {
897
+ "runtime": self.runtime,
898
+ "authStatus": "unknown",
899
+ "authMethods": methods,
900
+ "modelOptions": [],
901
+ "modeOptions": [],
902
+ "configOptions": [],
903
+ }
904
+ with tempfile.TemporaryDirectory(prefix="agent-link-acp-auth-") as tmp:
905
+ try:
906
+ session = await client.request(
907
+ "session/new",
908
+ {"cwd": tmp, "mcpServers": []},
909
+ timeout=_SESSION_NEW_TIMEOUT_S,
910
+ )
911
+ except AcpJsonRpcError as exc:
912
+ message = str(exc)
913
+ if _is_authish_error(message):
914
+ names = ", ".join(m["name"] for m in methods) or "interactive login"
915
+ probe["authStatus"] = "required"
916
+ probe["authHint"] = self.manifest.pre_auth_hint or (
917
+ f"{self.manifest.display_name} still requires authentication "
918
+ f"({names}). Complete login on the device running the connector, "
919
+ "then try again."
920
+ )
921
+ self._set_auth_status("required", probe["authHint"])
922
+ return probe
923
+ probe["authStatus"] = "unknown"
924
+ probe["error"] = message
925
+ self._set_auth_status("unknown")
926
+ return probe
927
+
928
+ probe["authStatus"] = "ok"
929
+ self._set_auth_status("ok")
930
+ config_options = session.get("configOptions") if isinstance(session.get("configOptions"), list) else []
931
+ config_options = [opt for opt in config_options if isinstance(opt, dict)]
932
+ if config_options:
933
+ probe["configOptions"] = config_options
934
+ models = extract_model_options(config_options)
935
+ modes = extract_mode_options(config_options)
936
+ if models:
937
+ probe["modelOptions"] = models
938
+ if modes:
939
+ probe["modeOptions"] = modes
940
+ session_id = session.get("sessionId") or session.get("session_id")
941
+ if isinstance(session_id, str) and session_id:
942
+ for method in ("session/close", "session/cancel"):
943
+ try:
944
+ if method == "session/cancel":
945
+ await client.notify(method, {"sessionId": session_id})
946
+ else:
947
+ await client.request(method, {"sessionId": session_id}, timeout=3.0)
948
+ break
949
+ except Exception:
950
+ continue
951
+ return probe
952
+
953
+ async def _emit_auth_status(self, probe: dict[str, Any]) -> None:
954
+ status = str(probe.get("authStatus") or "unknown")
955
+ hint = probe.get("authHint") if isinstance(probe.get("authHint"), str) else None
956
+ self._set_auth_status(status, hint)
957
+ payload: dict[str, Any] = {
958
+ "runtime": self.runtime,
959
+ "authStatus": status,
960
+ "authMethods": probe.get("authMethods") or summarize_auth_methods(self._auth_methods),
961
+ "sourceObservedAt": utc_now(),
962
+ }
963
+ if hint:
964
+ payload["authHint"] = hint
965
+ for key in ("configOptions", "modelOptions", "modeOptions"):
966
+ if probe.get(key) is not None:
967
+ payload[key] = probe[key]
968
+ await self._emit("runtime.optionsUpdated", payload)
969
+
970
+ async def _maybe_authenticate(self, client: AcpJsonRpcClient) -> bool:
971
+ """Best-effort headless auth only. Never open browser OAuth.
972
+
973
+ Avoids expensive session/new probes on every ensure_client (was adding
974
+ 10–20s before the first user turn). Real auth is verified on session/new.
975
+ """
976
+ if not self._auth_methods:
977
+ return True
978
+ method_ids = [
979
+ str(item.get("id") or item.get("methodId") or "")
980
+ for item in self._auth_methods
981
+ if (item.get("id") or item.get("methodId"))
982
+ ]
983
+ method_ids = [mid for mid in method_ids if mid]
984
+ ordered = order_headless_auth_method_ids(
985
+ method_ids,
986
+ preferred=list(self.manifest.preferred_auth_method_ids),
987
+ )
988
+ if not ordered:
989
+ # Interactive-only agents (CodeBuddy): skip; session/new will surface auth.
990
+ return True
991
+ for mid in ordered:
992
+ try:
993
+ await client.request(
994
+ "authenticate",
995
+ {"methodId": mid, "_meta": {"headless": True}},
996
+ timeout=4.0,
997
+ )
998
+ logger.info("{} authenticated via method={}", self.runtime, mid)
999
+ return True
1000
+ except Exception as exc:
1001
+ logger.debug(
1002
+ "{} authenticate method={} failed: {}",
1003
+ self.runtime,
1004
+ mid,
1005
+ exc,
1006
+ )
1007
+ continue
1008
+ # Do not fail ensure_client — create_session will report auth errors.
1009
+ logger.info(
1010
+ "{} headless auth not confirmed; will verify on session/new methods={}",
1011
+ self.runtime,
1012
+ method_ids,
1013
+ )
1014
+ return True
1015
+
1016
+ def _command(self) -> list[str]:
1017
+ if self.launch is not None:
1018
+ return self.launch.command(self.manifest.launch_args())
1019
+ binary = self.manifest.command[0]
1020
+ target = launch_target("cli", binary)
1021
+ if len(self.manifest.command) == 1:
1022
+ return target.command(self.manifest.launch_args())
1023
+ return list(self.manifest.command) + self.manifest.launch_args()
1024
+
1025
+ async def _on_notification(self, payload: dict[str, Any]) -> None:
1026
+ method = payload.get("method")
1027
+ if method != "session/update":
1028
+ return
1029
+ params = payload.get("params") if isinstance(payload.get("params"), dict) else {}
1030
+ external = _optional_string(params.get("sessionId") or params.get("session_id"))
1031
+ update = params.get("update") if isinstance(params.get("update"), dict) else {}
1032
+ runtime = self._session_by_external(external)
1033
+ if runtime is None or runtime.reducer is None or runtime.active_turn_id is None:
1034
+ return
1035
+ reduced = runtime.reducer.reduce_session_update(
1036
+ session_id=runtime.session_id,
1037
+ turn_id=runtime.active_turn_id,
1038
+ external_session_id=runtime.external_session_id,
1039
+ update=update,
1040
+ )
1041
+ await self._emit_reduction(reduced)
1042
+
1043
+ async def _on_server_request(
1044
+ self,
1045
+ request_id: str | int,
1046
+ method: str,
1047
+ params: dict[str, Any],
1048
+ ) -> dict[str, Any] | None:
1049
+ if method == "session/request_permission":
1050
+ return await self._handle_permission(request_id, params)
1051
+ if method.startswith("cursor/"):
1052
+ return self._handle_cursor_extension(method, params)
1053
+ if method.startswith("fs/"):
1054
+ if method == "fs/read_text_file":
1055
+ raise AcpJsonRpcError("fs/read_text_file not implemented in connector v1")
1056
+ raise AcpJsonRpcError(f"Unsupported fs method: {method}")
1057
+ logger.warning("ACP unsupported server request method={}", method)
1058
+ return {}
1059
+
1060
+ async def _handle_permission(
1061
+ self,
1062
+ request_id: str | int,
1063
+ params: dict[str, Any],
1064
+ ) -> dict[str, Any]:
1065
+ external = _optional_string(params.get("sessionId") or params.get("session_id"))
1066
+ runtime = self._session_by_external(external)
1067
+ if runtime is None or runtime.reducer is None:
1068
+ return {"outcome": {"outcome": "cancelled"}}
1069
+ turn_id = runtime.active_turn_id or f"turn_{self.runtime}_perm"
1070
+ reduced = runtime.reducer.reduce_permission_request(
1071
+ session_id=runtime.session_id,
1072
+ turn_id=turn_id,
1073
+ external_session_id=runtime.external_session_id,
1074
+ request_id=request_id,
1075
+ params=params,
1076
+ )
1077
+ await self._emit_reduction(reduced)
1078
+ approval = reduced.approval or {}
1079
+ approval_id = str(approval.get("id") or request_id)
1080
+ loop = asyncio.get_running_loop()
1081
+ future: asyncio.Future[str] = loop.create_future()
1082
+ options = params.get("options") if isinstance(params.get("options"), list) else []
1083
+ runtime.pending_approvals[approval_id] = _PendingApproval(
1084
+ request_id=request_id,
1085
+ future=future,
1086
+ options=[opt for opt in options if isinstance(opt, dict)],
1087
+ )
1088
+ try:
1089
+ status = await asyncio.wait_for(future, timeout=60 * 60)
1090
+ except TimeoutError:
1091
+ status = "cancelled"
1092
+ finally:
1093
+ runtime.pending_approvals.pop(approval_id, None)
1094
+ if status == "cancelled":
1095
+ return {"outcome": {"outcome": "cancelled"}}
1096
+ option_id = map_approval_status_to_option(status, options if isinstance(options, list) else [])
1097
+ if option_id is None:
1098
+ if status in {"approved", "approved_for_session"} and options:
1099
+ first = options[0] if isinstance(options[0], dict) else {}
1100
+ option_id = str(first.get("optionId") or first.get("option_id") or "allow-once")
1101
+ else:
1102
+ return {"outcome": {"outcome": "cancelled"}}
1103
+ return {"outcome": {"outcome": "selected", "optionId": option_id}}
1104
+
1105
+ def _handle_cursor_extension(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
1106
+ del params
1107
+ quirks = self.manifest.quirks.get("extensionMethods") if isinstance(self.manifest.quirks, dict) else {}
1108
+ policy = (quirks or {}).get(method, "ignore")
1109
+ if method == "cursor/ask_question":
1110
+ return {"outcome": {"outcome": "skipped", "reason": "not supported by AgentLink v1"}}
1111
+ if method == "cursor/create_plan":
1112
+ if policy in {"accept", "ignore"}:
1113
+ return {"outcome": {"outcome": "accepted"}}
1114
+ return {"outcome": {"outcome": "rejected", "reason": "not supported"}}
1115
+ return {"outcome": {"outcome": "accepted"}}
1116
+
1117
+ def _session_by_external(self, external: str | None) -> _SessionRuntime | None:
1118
+ if not external:
1119
+ return None
1120
+ for runtime in self._sessions.values():
1121
+ if runtime.external_session_id == external:
1122
+ return runtime
1123
+ return None
1124
+
1125
+ async def _emit_reduction(self, reduced: Any) -> None:
1126
+ if reduced.session_update:
1127
+ await self._emit("session.updated", reduced.session_update)
1128
+ for item in reduced.timeline_items:
1129
+ item_type = item.get("type") if isinstance(item, dict) else None
1130
+ timer = None
1131
+ session_id = item.get("sessionId") if isinstance(item, dict) else None
1132
+ if isinstance(session_id, str):
1133
+ session_runtime = self._sessions.get(session_id)
1134
+ timer = session_runtime.turn_timer if session_runtime is not None else None
1135
+ if timer is not None and item_type not in {"turn.start", "turn.end"}:
1136
+ if (
1137
+ item_type == "message"
1138
+ and isinstance(item, dict)
1139
+ and item.get("role") == "assistant"
1140
+ and isinstance(item.get("content"), dict)
1141
+ and (
1142
+ (isinstance(item["content"].get("text"), str) and item["content"]["text"].strip())
1143
+ or (isinstance(item["content"].get("rawText"), str) and item["content"]["rawText"].strip())
1144
+ )
1145
+ ):
1146
+ timer.mark_first_timeline(
1147
+ runtime=self.runtime,
1148
+ session_id=session_id,
1149
+ turn_id=item.get("turnId") if isinstance(item, dict) else None,
1150
+ stage_alias="adapter.first_assistant_token",
1151
+ )
1152
+ await self._emit("timeline.itemUpsert", {"sessionId": item["sessionId"], "item": item})
1153
+ if reduced.approval:
1154
+ await self._emit("approval.requested", reduced.approval)
1155
+
1156
+ async def _emit(self, method: str, params: dict[str, Any]) -> None:
1157
+ if self.notification_sink is None:
1158
+ return
1159
+ await self.notification_sink(method, params)
1160
+
1161
+
1162
+ def _required(params: dict[str, Any], key: str) -> str:
1163
+ value = params.get(key)
1164
+ if not isinstance(value, str) or not value:
1165
+ raise ValueError(f"missing {key}")
1166
+ return value
1167
+
1168
+
1169
+ def _optional_string(value: Any) -> str | None:
1170
+ return value if isinstance(value, str) and value else None
1171
+
1172
+
1173
+ def _require_cwd(params: dict[str, Any]) -> str:
1174
+ cwd = _optional_string(params.get("cwd"))
1175
+ if not cwd:
1176
+ raise ValueError("missing cwd: ACP sessions require an absolute workspace path")
1177
+ return cwd
1178
+
1179
+
1180
+ def _is_authish_error(message: str) -> bool:
1181
+ lowered = message.lower()
1182
+ return any(token in lowered for token in _AUTH_ERROR_TOKENS)
1183
+
1184
+
1185
+ def _attachments_metadata(params: dict[str, Any]) -> list[dict[str, Any]] | None:
1186
+ raw = params.get("timelineAttachments") or params.get("attachments")
1187
+ if not isinstance(raw, list):
1188
+ return None
1189
+ out: list[dict[str, Any]] = []
1190
+ for entry in raw:
1191
+ if not isinstance(entry, dict):
1192
+ continue
1193
+ file_id = entry.get("fileId") or entry.get("id")
1194
+ if not isinstance(file_id, str):
1195
+ continue
1196
+ item: dict[str, Any] = {"fileId": file_id}
1197
+ for key in ("name", "mediaType", "size", "sha256"):
1198
+ if entry.get(key) is not None:
1199
+ item[key] = entry[key]
1200
+ out.append(item)
1201
+ return out or None
1202
+
1203
+
1204
+ def _max_turn_seconds(manifest: AgentManifest) -> float | None:
1205
+ quirks = manifest.quirks if isinstance(manifest.quirks, dict) else {}
1206
+ raw = quirks.get("maxTurnSeconds", _DEFAULT_MAX_TURN_SECONDS)
1207
+ if raw is None:
1208
+ return None
1209
+ try:
1210
+ value = float(raw)
1211
+ except (TypeError, ValueError):
1212
+ return float(_DEFAULT_MAX_TURN_SECONDS)
1213
+ if value <= 0:
1214
+ return None
1215
+ return value
1216
+
1217
+
1218
+ def _connector_version() -> str:
1219
+ return connector_version()
1220
+
1221
+