cellcog 2.3.0__tar.gz → 2.4.0__tar.gz

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 (33) hide show
  1. {cellcog-2.3.0 → cellcog-2.4.0}/PKG-INFO +57 -6
  2. {cellcog-2.3.0 → cellcog-2.4.0}/README.md +56 -5
  3. {cellcog-2.3.0 → cellcog-2.4.0}/cellcog/__init__.py +1 -1
  4. {cellcog-2.3.0 → cellcog-2.4.0}/cellcog/chat.py +23 -1
  5. {cellcog-2.3.0 → cellcog-2.4.0}/cellcog/client.py +237 -32
  6. {cellcog-2.3.0 → cellcog-2.4.0}/cellcog/daemon/main.py +5 -4
  7. {cellcog-2.3.0 → cellcog-2.4.0}/cellcog/daemon/state.py +3 -1
  8. {cellcog-2.3.0 → cellcog-2.4.0}/cellcog/docs/api_reference.md +76 -7
  9. {cellcog-2.3.0 → cellcog-2.4.0}/cellcog/files.py +2 -1
  10. {cellcog-2.3.0 → cellcog-2.4.0}/cellcog/message_processor.py +2 -1
  11. cellcog-2.4.0/cellcog/state_paths.py +83 -0
  12. {cellcog-2.3.0 → cellcog-2.4.0}/pyproject.toml +1 -1
  13. {cellcog-2.3.0 → cellcog-2.4.0}/.convex/.gitignore +0 -0
  14. {cellcog-2.3.0 → cellcog-2.4.0}/.convex/local/default/config.json +0 -0
  15. {cellcog-2.3.0 → cellcog-2.4.0}/.convex/local/default/convex_local_backend.sqlite3 +0 -0
  16. {cellcog-2.3.0 → cellcog-2.4.0}/.convex/local/default/convex_local_storage/modules/18330a2b-2cf5-4f51-89a5-8d97a422e7a2.blob +0 -0
  17. {cellcog-2.3.0 → cellcog-2.4.0}/.gitignore +0 -0
  18. {cellcog-2.3.0 → cellcog-2.4.0}/LICENSE +0 -0
  19. {cellcog-2.3.0 → cellcog-2.4.0}/cellcog/auth.py +0 -0
  20. {cellcog-2.3.0 → cellcog-2.4.0}/cellcog/config.py +0 -0
  21. {cellcog-2.3.0 → cellcog-2.4.0}/cellcog/daemon/__init__.py +0 -0
  22. {cellcog-2.3.0 → cellcog-2.4.0}/cellcog/daemon/delivery.py +0 -0
  23. {cellcog-2.3.0 → cellcog-2.4.0}/cellcog/docs/__init__.py +0 -0
  24. {cellcog-2.3.0 → cellcog-2.4.0}/cellcog/docs/support.md +0 -0
  25. {cellcog-2.3.0 → cellcog-2.4.0}/cellcog/exceptions.py +0 -0
  26. {cellcog-2.3.0 → cellcog-2.4.0}/cellcog/message_formatter.py +0 -0
  27. {cellcog-2.3.0 → cellcog-2.4.0}/cellcog/version_detection.py +0 -0
  28. {cellcog-2.3.0 → cellcog-2.4.0}/examples/basic_usage.py +0 -0
  29. {cellcog-2.3.0 → cellcog-2.4.0}/examples/file_handling.py +0 -0
  30. {cellcog-2.3.0 → cellcog-2.4.0}/examples/streaming_usage.py +0 -0
  31. {cellcog-2.3.0 → cellcog-2.4.0}/examples/sync_usage.py +0 -0
  32. {cellcog-2.3.0 → cellcog-2.4.0}/examples/workflow_usage.py +0 -0
  33. {cellcog-2.3.0 → cellcog-2.4.0}/tests/test_gs_url_handling.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: cellcog
3
- Version: 2.3.0
3
+ Version: 2.4.0
4
4
  Summary: Python SDK for CellCog - Any-to-Any AI for agents. Fire-and-forget pattern with WebSocket notifications.
5
5
  Project-URL: Homepage, https://cellcog.ai
6
6
  Project-URL: Documentation, https://cellcog.ai/developer/docs
@@ -78,11 +78,13 @@ result = client.create_chat(
78
78
 
79
79
  ## How It Works
80
80
 
81
- **Two delivery modes:**
81
+ **Three delivery modes:**
82
82
 
83
83
  - **Wait for Completion (default)** — Blocks until CellCog finishes and returns the full result. Works with any agent — OpenClaw, Claude Code, Cursor, or any Python environment.
84
84
 
85
- - **Fire-and-Forget (OpenClaw)** — Returns immediately. A background daemon monitors via WebSocket and delivers results to your OpenClaw session when done. Requires `sessions_send` on OpenClaw Gateway.
85
+ - **Send-only (`delivery="send_only"`)** — Fire-and-forget for ANY agent: returns the moment the chat/message is accepted (`status="accepted"`). Poll `get_status()` and fetch results with `wait_for_completion()`/`get_history()`. Use this when orchestrating several chats in parallel — the blocking default would hang on each chat's whole run. Works on `create_chat()` AND `send_message()`.
86
+
87
+ - **Notify on Completion (OpenClaw)** — Returns immediately. A background daemon monitors via WebSocket and delivers results to your OpenClaw session when done. Requires `sessions_send` on OpenClaw Gateway.
86
88
 
87
89
  All methods return the same unified shape:
88
90
 
@@ -90,15 +92,23 @@ All methods return the same unified shape:
90
92
  {
91
93
  "chat_id": str,
92
94
  "is_operating": bool,
93
- "status": str, # "completed" | "tracking" | "timeout" | "operating"
95
+ "status": str, # "completed" | "tracking" | "accepted" | "operating"
94
96
  "message": str, # Always print this in full
95
97
  }
96
98
  ```
97
99
 
100
+ A wait that gives up returns `status="operating"` with `timed_out=True` — the chat is still
101
+ running server-side; resume with `wait_for_completion()`.
102
+
98
103
  ## Configuration
99
104
 
100
105
  ```bash
101
106
  export CELLCOG_API_KEY="sk_..."
107
+
108
+ # Optional: where SDK state lives (tracked chats, downloads, daemon files).
109
+ # Default ~/.cellcog; in headless/container environments where HOME isn't
110
+ # writable, the SDK falls back to a temp directory automatically.
111
+ export CELLCOG_STATE_DIR="/path/to/state"
102
112
  ```
103
113
 
104
114
  **Get your API key:**
@@ -148,13 +158,48 @@ result = client.create_chat(
148
158
  prompt="...",
149
159
  task_label="...",
150
160
  chat_mode="agent",
161
+ chat_tier="max", # "flash" | "core" | "max" — omit for the SDK default
162
+ delivery="send_only", # fire-and-forget; default blocks until done
151
163
  project_id="...", # CellCog project for document context
152
164
  agent_role_id="...", # Specialized agent role
153
165
  enable_cowork=True, # Direct machine access via CellCog Desktop
154
166
  cowork_working_directory="/Users/...", # Working directory for co-work
167
+ enable_browse=True, # Drive the user's REAL Chrome (needs Desktop + extension)
168
+ browser_profile_id="Default", # from client.get_browser_status()
169
+ enable_tools=True, # the user's connected SaaS tools
170
+ tools_selection=["gmail", "notion"], # toolkit slugs (omit for ALL); from client.list_toolkits()
155
171
  )
156
172
  ```
157
173
 
174
+ ## Browse & Tools
175
+
176
+ CellCog chats can drive the user's **real Chrome** (Browse) and call their **connected
177
+ SaaS tools** (Gmail, Notion, Slack, ... — Tools). Discover what's available, then enable
178
+ at chat creation:
179
+
180
+ ```python
181
+ # Browse: discover Chrome profiles, then enable with one
182
+ status = client.get_browser_status()
183
+ profile = status["active_profile"] # or let the user pick from available_profiles
184
+ result = client.create_chat(
185
+ prompt="Log into the dashboard and screenshot the weekly report",
186
+ enable_browse=True,
187
+ browser_profile_id=profile["profileDir"],
188
+ )
189
+
190
+ # Tools: discover connected toolkits, then enable a selection (omit for ALL)
191
+ toolkits = client.list_toolkits(connected_only=True)
192
+ result = client.create_chat(
193
+ prompt="Summarize this week's unread emails",
194
+ enable_tools=True,
195
+ tools_selection=["gmail"], # toolkit slugs
196
+ )
197
+ ```
198
+
199
+ Notes: Browse requires CellCog Desktop + the Chrome extension on the user's machine and
200
+ auto-enables co-work server-side. `tools_selection` takes TOOLKIT slugs (the granularity
201
+ users pick in the product UI); `list_toolkit_tools(slug)` shows what a toolkit unlocks.
202
+
158
203
  ## File Handling
159
204
 
160
205
  ```python
@@ -180,9 +225,15 @@ a tier for the DEPTH (omit `chat_tier` for the mode's default).
180
225
 
181
226
  | Mode | Best For | Tiers |
182
227
  |------|----------|-------|
183
- | `"agent"` | Most tasks — building, research, images, audio, documents, coding/co-work | `flash` · `core` · `max` (default) |
228
+ | `"agent"` | Most tasks — assets, documents, production pipelines, coding/co-work | `flash` (SDK default) · `core` · `max` |
184
229
  | `"creative"` | Design, brand, frontend craft — taste-first work | `core` (default) · `max` |
185
- | `"team"` | Deep multi-angled research & analysis (multi-agent) | `flash` · `core` (default) · `max` |
230
+ | `"team"` | Deep research ONLY — multi-source synthesis, citations (multi-agent) | `flash` · `core` (default) · `max` |
231
+
232
+ **Tier guidance for agent mode:** omit `chat_tier` and the SDK sends `"flash"` — right for
233
+ simple asset generation and light tasks. **Coding / co-work needs `"max"`** (applied
234
+ automatically when `enable_cowork=True`). Heavy multi-step production (video, data
235
+ analysis, financial models, legal drafting) → pass `chat_tier="max"` explicitly. Quality
236
+ disappointing on flash? Re-run with `chat_tier="max"`. Use `"team"` only for deep research.
186
237
 
187
238
  Legacy mode names (`"agent core"`, `"agent team"`, `"agent team max"`) keep working
188
239
  forever — the server normalizes them to their historical operating points.
@@ -46,11 +46,13 @@ result = client.create_chat(
46
46
 
47
47
  ## How It Works
48
48
 
49
- **Two delivery modes:**
49
+ **Three delivery modes:**
50
50
 
51
51
  - **Wait for Completion (default)** — Blocks until CellCog finishes and returns the full result. Works with any agent — OpenClaw, Claude Code, Cursor, or any Python environment.
52
52
 
53
- - **Fire-and-Forget (OpenClaw)** — Returns immediately. A background daemon monitors via WebSocket and delivers results to your OpenClaw session when done. Requires `sessions_send` on OpenClaw Gateway.
53
+ - **Send-only (`delivery="send_only"`)** — Fire-and-forget for ANY agent: returns the moment the chat/message is accepted (`status="accepted"`). Poll `get_status()` and fetch results with `wait_for_completion()`/`get_history()`. Use this when orchestrating several chats in parallel — the blocking default would hang on each chat's whole run. Works on `create_chat()` AND `send_message()`.
54
+
55
+ - **Notify on Completion (OpenClaw)** — Returns immediately. A background daemon monitors via WebSocket and delivers results to your OpenClaw session when done. Requires `sessions_send` on OpenClaw Gateway.
54
56
 
55
57
  All methods return the same unified shape:
56
58
 
@@ -58,15 +60,23 @@ All methods return the same unified shape:
58
60
  {
59
61
  "chat_id": str,
60
62
  "is_operating": bool,
61
- "status": str, # "completed" | "tracking" | "timeout" | "operating"
63
+ "status": str, # "completed" | "tracking" | "accepted" | "operating"
62
64
  "message": str, # Always print this in full
63
65
  }
64
66
  ```
65
67
 
68
+ A wait that gives up returns `status="operating"` with `timed_out=True` — the chat is still
69
+ running server-side; resume with `wait_for_completion()`.
70
+
66
71
  ## Configuration
67
72
 
68
73
  ```bash
69
74
  export CELLCOG_API_KEY="sk_..."
75
+
76
+ # Optional: where SDK state lives (tracked chats, downloads, daemon files).
77
+ # Default ~/.cellcog; in headless/container environments where HOME isn't
78
+ # writable, the SDK falls back to a temp directory automatically.
79
+ export CELLCOG_STATE_DIR="/path/to/state"
70
80
  ```
71
81
 
72
82
  **Get your API key:**
@@ -116,13 +126,48 @@ result = client.create_chat(
116
126
  prompt="...",
117
127
  task_label="...",
118
128
  chat_mode="agent",
129
+ chat_tier="max", # "flash" | "core" | "max" — omit for the SDK default
130
+ delivery="send_only", # fire-and-forget; default blocks until done
119
131
  project_id="...", # CellCog project for document context
120
132
  agent_role_id="...", # Specialized agent role
121
133
  enable_cowork=True, # Direct machine access via CellCog Desktop
122
134
  cowork_working_directory="/Users/...", # Working directory for co-work
135
+ enable_browse=True, # Drive the user's REAL Chrome (needs Desktop + extension)
136
+ browser_profile_id="Default", # from client.get_browser_status()
137
+ enable_tools=True, # the user's connected SaaS tools
138
+ tools_selection=["gmail", "notion"], # toolkit slugs (omit for ALL); from client.list_toolkits()
123
139
  )
124
140
  ```
125
141
 
142
+ ## Browse & Tools
143
+
144
+ CellCog chats can drive the user's **real Chrome** (Browse) and call their **connected
145
+ SaaS tools** (Gmail, Notion, Slack, ... — Tools). Discover what's available, then enable
146
+ at chat creation:
147
+
148
+ ```python
149
+ # Browse: discover Chrome profiles, then enable with one
150
+ status = client.get_browser_status()
151
+ profile = status["active_profile"] # or let the user pick from available_profiles
152
+ result = client.create_chat(
153
+ prompt="Log into the dashboard and screenshot the weekly report",
154
+ enable_browse=True,
155
+ browser_profile_id=profile["profileDir"],
156
+ )
157
+
158
+ # Tools: discover connected toolkits, then enable a selection (omit for ALL)
159
+ toolkits = client.list_toolkits(connected_only=True)
160
+ result = client.create_chat(
161
+ prompt="Summarize this week's unread emails",
162
+ enable_tools=True,
163
+ tools_selection=["gmail"], # toolkit slugs
164
+ )
165
+ ```
166
+
167
+ Notes: Browse requires CellCog Desktop + the Chrome extension on the user's machine and
168
+ auto-enables co-work server-side. `tools_selection` takes TOOLKIT slugs (the granularity
169
+ users pick in the product UI); `list_toolkit_tools(slug)` shows what a toolkit unlocks.
170
+
126
171
  ## File Handling
127
172
 
128
173
  ```python
@@ -148,9 +193,15 @@ a tier for the DEPTH (omit `chat_tier` for the mode's default).
148
193
 
149
194
  | Mode | Best For | Tiers |
150
195
  |------|----------|-------|
151
- | `"agent"` | Most tasks — building, research, images, audio, documents, coding/co-work | `flash` · `core` · `max` (default) |
196
+ | `"agent"` | Most tasks — assets, documents, production pipelines, coding/co-work | `flash` (SDK default) · `core` · `max` |
152
197
  | `"creative"` | Design, brand, frontend craft — taste-first work | `core` (default) · `max` |
153
- | `"team"` | Deep multi-angled research & analysis (multi-agent) | `flash` · `core` (default) · `max` |
198
+ | `"team"` | Deep research ONLY — multi-source synthesis, citations (multi-agent) | `flash` · `core` (default) · `max` |
199
+
200
+ **Tier guidance for agent mode:** omit `chat_tier` and the SDK sends `"flash"` — right for
201
+ simple asset generation and light tasks. **Coding / co-work needs `"max"`** (applied
202
+ automatically when `enable_cowork=True`). Heavy multi-step production (video, data
203
+ analysis, financial models, legal drafting) → pass `chat_tier="max"` explicitly. Quality
204
+ disappointing on flash? Re-run with `chat_tier="max"`. Use `"team"` only for deep research.
154
205
 
155
206
  Legacy mode names (`"agent core"`, `"agent team"`, `"agent team max"`) keep working
156
207
  forever — the server normalizes them to their historical operating points.
@@ -48,7 +48,7 @@ from .exceptions import (
48
48
  SDKUpgradeRequiredError,
49
49
  )
50
50
 
51
- __version__ = "2.3.0"
51
+ __version__ = "2.4.0"
52
52
  __all__ = [
53
53
  "CellCogClient",
54
54
  "CellCogError",
@@ -19,6 +19,7 @@ from .exceptions import (
19
19
  PaymentRequiredError,
20
20
  )
21
21
  from .files import FileProcessor
22
+ from .state_paths import state_path
22
23
 
23
24
  # ── Mode × Tier resolution (Mode × Tier redesign, 2026-08) ──────────────────
24
25
  # Friendly SDK names -> API chat_mode values. The server accepts BOTH current
@@ -80,7 +81,7 @@ class ChatManager:
80
81
  def __init__(self, config: Config, file_processor: FileProcessor):
81
82
  self.config = config
82
83
  self.files = file_processor
83
- self._chats_dir = Path("~/.cellcog/chats").expanduser()
84
+ self._chats_dir = state_path("chats")
84
85
 
85
86
  def create(
86
87
  self,
@@ -91,6 +92,10 @@ class ChatManager:
91
92
  hc_enabled: bool = False,
92
93
  hc_working_directory: Optional[str] = None,
93
94
  chat_tier: Optional[str] = None,
95
+ browser_enabled: bool = False,
96
+ browser_profile_id: Optional[str] = None,
97
+ personal_tools_enabled: bool = False,
98
+ personal_tools_selection: Optional[list] = None,
94
99
  ) -> dict:
95
100
  """
96
101
  Create a new CellCog chat (without streaming).
@@ -107,6 +112,13 @@ class ChatManager:
107
112
  agent_role_id: Optional agent role ID within the project. Requires project_id.
108
113
  hc_enabled: Enable Human Computer (co-work) for this chat
109
114
  hc_working_directory: Working directory on user's machine for HC commands
115
+ browser_enabled: Enable Browse (the user's real Chrome). Server-side this
116
+ auto-enables co-work (Browse rides the Desktop connection).
117
+ browser_profile_id: Chrome profile dir (from the browser-status endpoint's
118
+ available_profiles[].profileDir). None -> "Default".
119
+ personal_tools_enabled: Enable the user's connected SaaS tools for this chat
120
+ personal_tools_selection: Toolkit slugs to restrict the chat to (e.g.
121
+ ["gmail", "notion"]). None -> ALL connected tools.
110
122
 
111
123
  Returns:
112
124
  {
@@ -133,6 +145,13 @@ class ChatManager:
133
145
  data["hc_enabled"] = True
134
146
  if hc_working_directory:
135
147
  data["hc_working_directory"] = hc_working_directory
148
+ if browser_enabled:
149
+ data["browser_enabled"] = True
150
+ data["browser_profile_id"] = browser_profile_id or "Default"
151
+ if personal_tools_enabled:
152
+ data["personal_tools_enabled"] = True
153
+ if personal_tools_selection is not None:
154
+ data["personal_tools_selection"] = [str(s).strip().lower() for s in personal_tools_selection]
136
155
 
137
156
  resp = self._request("POST", "/cellcog/chat/new", data)
138
157
 
@@ -192,6 +211,9 @@ class ChatManager:
192
211
  "name": resp["name"],
193
212
  "is_operating": resp["operating"],
194
213
  "error_type": error_type,
214
+ # Most recent agent progress one-liner (server: ChatMeta.latest_status_update,
215
+ # CEL-1300 item 6). None until the agent posts its first update of a run.
216
+ "latest_update": resp.get("latest_status_update"),
195
217
  }
196
218
 
197
219
  def get_history(self, chat_id: str, session_id: str) -> dict:
@@ -22,11 +22,12 @@ from typing import Optional
22
22
  import requests as _requests
23
23
 
24
24
  from .auth import AuthManager
25
- from .chat import ChatManager
25
+ from .chat import ChatManager, resolve_chat_mode
26
26
  from .config import Config
27
27
  from .exceptions import APIError, GatewayConfigError
28
28
  from .files import FileProcessor
29
29
  from .message_processor import MessageProcessor
30
+ from .state_paths import state_dir, state_path
30
31
  from .daemon.state import TrackedChat, Listener, StateManager
31
32
 
32
33
 
@@ -140,7 +141,7 @@ class CellCogClient:
140
141
  self._state = StateManager()
141
142
 
142
143
  # Daemon management
143
- self._daemon_pid_file = Path("~/.cellcog/daemon.pid").expanduser()
144
+ self._daemon_pid_file = state_path("daemon.pid")
144
145
 
145
146
  # Sessions send pre-flight check cache
146
147
  # Maps gateway_url -> (is_available: bool, checked_at: float)
@@ -280,13 +281,20 @@ class CellCogClient:
280
281
  enable_cowork: bool = False,
281
282
  cowork_working_directory: Optional[str] = None,
282
283
  chat_tier: Optional[str] = None,
284
+ enable_browse: bool = False,
285
+ browser_profile_id: Optional[str] = None,
286
+ enable_tools: bool = False,
287
+ tools_selection: Optional[list] = None,
283
288
  ) -> dict:
284
289
  """
285
290
  Create a CellCog chat.
286
291
 
287
- Two delivery modes:
292
+ Three delivery modes:
288
293
  - "wait_for_completion" (default): Blocks until done. Returns full results.
289
294
  Works with ANY agent (OpenClaw, Cursor, Claude Code, etc.).
295
+ - "send_only": Fire-and-forget. Returns immediately with status="accepted";
296
+ no daemon needed. Poll get_status()/wait_for_completion() yourself.
297
+ Best for orchestrating several chats in parallel.
290
298
  - "notify_on_completion": Returns immediately. Daemon delivers results
291
299
  to your OpenClaw session via sessions_send. Requires notify_session_key.
292
300
 
@@ -296,8 +304,10 @@ class CellCogClient:
296
304
  e.g., "agent:main:main"). If provided without explicit delivery param,
297
305
  automatically uses notify_on_completion for backward compatibility.
298
306
  task_label: Human-readable label (appears in notification)
299
- delivery: "wait_for_completion" (default, universal) or
300
- "notify_on_completion" (OpenClaw only, fire-and-forget)
307
+ delivery: "wait_for_completion" (default, universal, blocking),
308
+ "send_only" (fire-and-forget: returns once the chat is accepted;
309
+ poll with get_status/wait_for_completion), or
310
+ "notify_on_completion" (OpenClaw sessions_send delivery)
301
311
  timeout: Max seconds to wait (wait_for_completion mode only, default 1800)
302
312
  gateway_url: OpenClaw Gateway URL (notify_on_completion only)
303
313
  project_id: Optional CellCog project ID for document context
@@ -305,9 +315,21 @@ class CellCogClient:
305
315
  chat_mode: "agent", "creative", or "team". Legacy names — "agent core",
306
316
  "agent team", "agent team max" — keep working forever.
307
317
  chat_tier: "flash" | "core" | "max" — depth/spend within the mode.
308
- None -> the mode's default tier. "creative" has no "flash" tier.
318
+ None in agent mode -> SDK default: "flash" (or "max" when
319
+ enable_cowork=True — coding needs depth). Other modes: the
320
+ server applies the mode's default. "creative" has no "flash" tier.
309
321
  enable_cowork: Enable co-work on user's PC
310
322
  cowork_working_directory: Working directory for co-work commands
323
+ enable_browse: Enable Browse — the agent drives the user's REAL Chrome
324
+ (requires CellCog Desktop + the Chrome extension). Auto-enables
325
+ co-work server-side. Discover profiles via get_browser_status().
326
+ browser_profile_id: Chrome profile to browse with — a profileDir from
327
+ get_browser_status()["available_profiles"]. None -> "Default".
328
+ enable_tools: Enable the user's connected SaaS tools (Gmail, Notion, ...)
329
+ for this chat. Discover toolkits via list_toolkits().
330
+ tools_selection: Restrict the chat to these toolkit slugs (e.g.
331
+ ["gmail", "notion"]). None -> ALL connected tools. no_auth built-in
332
+ toolkits work regardless of this selection.
311
333
 
312
334
  Returns:
313
335
  All modes return: {"chat_id": str, "is_operating": bool, "status": str, "message": str}
@@ -347,10 +369,11 @@ class CellCogClient:
347
369
  if notify_session_key and delivery == "wait_for_completion":
348
370
  delivery = "notify_on_completion"
349
371
 
350
- if delivery not in ("notify_on_completion", "wait_for_completion"):
372
+ if delivery not in ("notify_on_completion", "wait_for_completion", "send_only"):
351
373
  raise ValueError(
352
374
  f"Invalid delivery mode: '{delivery}'. "
353
- "Use 'notify_on_completion' (OpenClaw) or 'wait_for_completion' (universal)."
375
+ "Use 'wait_for_completion' (universal, blocking), 'send_only' "
376
+ "(fire-and-forget, poll later), or 'notify_on_completion' (OpenClaw)."
354
377
  )
355
378
 
356
379
  if delivery == "notify_on_completion" and not notify_session_key:
@@ -370,6 +393,49 @@ class CellCogClient:
370
393
  if delivery == "notify_on_completion":
371
394
  self._require_sessions_send(gateway_url)
372
395
 
396
+ # ── Tools selection validation (toolkit slugs only) ──
397
+ if tools_selection is not None:
398
+ if not enable_tools:
399
+ raise ValueError("tools_selection requires enable_tools=True")
400
+ if not isinstance(tools_selection, (list, tuple)) or not all(
401
+ isinstance(s, str) and s.strip() for s in tools_selection
402
+ ):
403
+ raise ValueError(
404
+ "tools_selection must be a list of toolkit slugs (strings), e.g. "
405
+ "['gmail', 'notion'] — discover slugs via client.list_toolkits()."
406
+ )
407
+
408
+ # ── Browse pre-flight: fail fast BEFORE creating (and billing) a chat whose
409
+ # browser commands could never run. Best-effort — a status-fetch error never blocks.
410
+ if enable_browse:
411
+ try:
412
+ _bstatus = self.get_browser_status()
413
+ except Exception:
414
+ _bstatus = None
415
+ if _bstatus is not None:
416
+ _profiles = _bstatus.get("available_profiles") or []
417
+ if not any(p.get("hasExtension") for p in _profiles):
418
+ raise ValueError(
419
+ "enable_browse=True but no Chrome profile has the CellCog extension "
420
+ "(or CellCog Desktop is not connected). The user must connect Desktop "
421
+ "and install the extension first — check client.get_browser_status()."
422
+ )
423
+ if browser_profile_id and not any(
424
+ p.get("profileDir") == browser_profile_id for p in _profiles
425
+ ):
426
+ _known = ", ".join(sorted(str(p.get("profileDir")) for p in _profiles))
427
+ raise ValueError(
428
+ f"browser_profile_id {browser_profile_id!r} not found in the user's "
429
+ f"Chrome. Known profiles: {_known}. Pick a profileDir from "
430
+ "client.get_browser_status()['available_profiles']."
431
+ )
432
+
433
+ # ── SDK tier default (Mode × Tier rollout, 2026-08): when no chat_tier is
434
+ # given in agent mode, default to the LOWEST tier — "flash" — except co-work
435
+ # (coding on a real machine), which needs "max". Explicit chat_tier always wins.
436
+ if chat_tier is None and resolve_chat_mode(chat_mode) == "human_in_the_loop":
437
+ chat_tier = "max" if enable_cowork else "flash"
438
+
373
439
  # ── Create the chat via API ──
374
440
  api_result = self._chat.create(
375
441
  prompt, project_id, chat_mode,
@@ -377,10 +443,28 @@ class CellCogClient:
377
443
  agent_role_id=agent_role_id,
378
444
  hc_enabled=enable_cowork,
379
445
  hc_working_directory=cowork_working_directory,
446
+ browser_enabled=enable_browse,
447
+ browser_profile_id=browser_profile_id,
448
+ personal_tools_enabled=enable_tools,
449
+ personal_tools_selection=list(tools_selection) if tools_selection is not None else None,
380
450
  )
381
451
  chat_id = api_result["chat_id"]
382
452
  task_label = task_label or f"chat-{chat_id[:8]}"
383
453
 
454
+ # ── send_only: fire-and-forget — no tracking, no daemon, return at once ──
455
+ if delivery == "send_only":
456
+ return {
457
+ "chat_id": chat_id,
458
+ "is_operating": True,
459
+ "status": "accepted",
460
+ "message": (
461
+ f"Chat {chat_id} accepted and running on CellCog (fire-and-forget).\n"
462
+ f'Poll progress: client.get_status("{chat_id}")\n'
463
+ f'Block until done: client.wait_for_completion("{chat_id}", timeout=1800)\n'
464
+ f'Fetch results: client.get_history("{chat_id}")'
465
+ ),
466
+ }
467
+
384
468
  # ── Local setup: tracking + daemon + delivery ──
385
469
  # Wrapped in try-except so chat_id is always returned even if local
386
470
  # setup fails. The chat was already created server-side and billed —
@@ -448,6 +532,10 @@ class CellCogClient:
448
532
 
449
533
  Same delivery modes as create_chat:
450
534
  - "wait_for_completion" (default): Blocks until done. Returns full results.
535
+ - "send_only": Fire-and-forget. Returns immediately after the message is
536
+ accepted; poll get_status()/wait_for_completion() yourself. Use this when
537
+ relaying messages to several chats — the blocking default would hang on
538
+ each chat's entire run.
451
539
  - "notify_on_completion": Returns immediately. Daemon delivers later.
452
540
 
453
541
  Args:
@@ -455,7 +543,8 @@ class CellCogClient:
455
543
  message: Your message (supports SHOW_FILE tags for file uploads)
456
544
  notify_session_key: OpenClaw session key (required for notify_on_completion)
457
545
  task_label: Label for notification (default: "continue-{chat_id[:8]}")
458
- delivery: "wait_for_completion" or "notify_on_completion"
546
+ delivery: "wait_for_completion" (blocking, default), "send_only"
547
+ (fire-and-forget), or "notify_on_completion" (OpenClaw)
459
548
  timeout: Max seconds to wait (wait_for_completion only, default 1800)
460
549
  gateway_url: OpenClaw Gateway URL (notify_on_completion only)
461
550
 
@@ -468,10 +557,11 @@ class CellCogClient:
468
557
  if notify_session_key and delivery == "wait_for_completion":
469
558
  delivery = "notify_on_completion"
470
559
 
471
- if delivery not in ("notify_on_completion", "wait_for_completion"):
560
+ if delivery not in ("notify_on_completion", "wait_for_completion", "send_only"):
472
561
  raise ValueError(
473
562
  f"Invalid delivery mode: '{delivery}'. "
474
- "Use 'notify_on_completion' (OpenClaw) or 'wait_for_completion' (universal)."
563
+ "Use 'wait_for_completion' (universal, blocking), 'send_only' "
564
+ "(fire-and-forget, poll later), or 'notify_on_completion' (OpenClaw)."
475
565
  )
476
566
 
477
567
  if delivery == "notify_on_completion" and not notify_session_key:
@@ -494,6 +584,19 @@ class CellCogClient:
494
584
  # ── Send message via API ──
495
585
  api_result = self._chat.send_message(chat_id, message)
496
586
 
587
+ # ── send_only: fire-and-forget — message delivered, return at once ──
588
+ if delivery == "send_only":
589
+ return {
590
+ "chat_id": chat_id,
591
+ "is_operating": True,
592
+ "status": "accepted",
593
+ "message": (
594
+ f"Message delivered to chat {chat_id}; CellCog is working (fire-and-forget).\n"
595
+ f'Poll progress: client.get_status("{chat_id}")\n'
596
+ f'Block until done: client.wait_for_completion("{chat_id}", timeout=1800)'
597
+ ),
598
+ }
599
+
497
600
  # ── Local setup (same protection as create_chat) ──
498
601
  try:
499
602
  tracking_info = self._track_chat(
@@ -630,11 +733,80 @@ class CellCogClient:
630
733
  "status": "processing" | "ready" | "error",
631
734
  "name": str,
632
735
  "is_operating": bool,
633
- "error_type": str | None
736
+ "error_type": str | None,
737
+ "latest_update": str | None # most recent agent progress one-liner
634
738
  }
635
739
  """
636
740
  return self._chat.get_status(chat_id)
637
741
 
742
+ # ==================== Capability Discovery (Browse & Tools) ====================
743
+
744
+ def get_browser_status(self) -> dict:
745
+ """
746
+ Browse availability on the user's machine — use this to populate a
747
+ profile picker before create_chat(enable_browse=True).
748
+
749
+ Returns:
750
+ {
751
+ "extension_installed": bool, # CellCog Chrome extension present?
752
+ "chrome_running": bool, # Chrome connected to Desktop?
753
+ "active_profile": dict | None, # sensible default profile
754
+ "available_profiles": [ # one per Chrome profile
755
+ {"profileDir": str, # -> pass as browser_profile_id
756
+ "profileName": str,
757
+ "hostedDomain": str | None,
758
+ "hasExtension": bool, # extension installed in this profile
759
+ "connected": bool}, # currently connected via Desktop
760
+ ...
761
+ ],
762
+ "agent_tab_count": int,
763
+ }
764
+ """
765
+ self.config.require_configured()
766
+ return self._chat._request("GET", "/cellcog/human-computer/browser/status")
767
+
768
+ def list_toolkits(self, connected_only: bool = False, search: Optional[str] = None) -> list:
769
+ """
770
+ The SaaS toolkit catalog with the user's connection state — use this to
771
+ populate a tool picker before create_chat(enable_tools=True).
772
+
773
+ Args:
774
+ connected_only: Only toolkits the user has actually connected —
775
+ the set that matters for tools_selection.
776
+ search: Optional name filter (server-side, case-insensitive).
777
+
778
+ Returns:
779
+ List of toolkit dicts; each has "slug" (-> pass in tools_selection),
780
+ "name", and "connection" (dict when connected, None otherwise).
781
+ """
782
+ self.config.require_configured()
783
+ from urllib.parse import quote
784
+
785
+ items, offset, limit = [], 0, 200
786
+ while True:
787
+ path = f"/cellcog/personal-tools/toolkits?limit={limit}&offset={offset}"
788
+ if search:
789
+ path += f"&search={quote(search)}"
790
+ resp = self._chat._request("GET", path)
791
+ page = resp.get("items", [])
792
+ items.extend(page)
793
+ total = resp.get("total", len(items))
794
+ offset += limit
795
+ if len(items) >= total or not page or offset > 5000:
796
+ break
797
+ if connected_only:
798
+ items = [t for t in items if t.get("connection")]
799
+ return items
800
+
801
+ def list_toolkit_tools(self, toolkit_slug: str) -> list:
802
+ """
803
+ The individual tools (action slugs) a toolkit carries — for showing users
804
+ what enabling a toolkit unlocks. (tools_selection itself takes TOOLKIT
805
+ slugs, not tool slugs.)
806
+ """
807
+ self.config.require_configured()
808
+ return self._chat._request("GET", f"/cellcog/personal-tools/toolkits/{toolkit_slug}/tools")
809
+
638
810
  # ==================== Chat Tracking Recovery ====================
639
811
 
640
812
  def restart_chat_tracking(self) -> dict:
@@ -670,7 +842,7 @@ class CellCogClient:
670
842
  self._kill_daemon_if_running()
671
843
 
672
844
  # Count tracking files to report
673
- tracked_dir = Path("~/.cellcog/tracked_chats").expanduser()
845
+ tracked_dir = state_path("tracked_chats")
674
846
  tracked_count = sum(1 for _ in tracked_dir.glob("*.json")) if tracked_dir.exists() else 0
675
847
 
676
848
  if tracked_count == 0:
@@ -698,7 +870,7 @@ class CellCogClient:
698
870
  return {
699
871
  "status": "failed",
700
872
  "tracked_chats": tracked_count,
701
- "message": "Failed to start daemon. Check ~/.cellcog/daemon.log"
873
+ "message": f"Failed to start daemon. Check {state_path('daemon.log')}"
702
874
  }
703
875
 
704
876
  # ==================== Synchronous Wait ====================
@@ -721,7 +893,8 @@ class CellCogClient:
721
893
 
722
894
  On completion: status="completed", message contains full response with
723
895
  file paths, credits used, and next steps.
724
- On timeout: status="timeout", message contains progress updates and
896
+ On timeout: status="operating" with timed_out=True (the chat is STILL
897
+ running server-side); message contains progress updates and
725
898
  retry guidance.
726
899
 
727
900
  Examples:
@@ -1021,7 +1194,7 @@ class CellCogClient:
1021
1194
 
1022
1195
  # Read interim updates file written by daemon
1023
1196
  try:
1024
- updates_file = Path("~/.cellcog/chats").expanduser() / chat_id / ".interim_updates.json"
1197
+ updates_file = state_path("chats") / chat_id / ".interim_updates.json"
1025
1198
  if updates_file.exists():
1026
1199
  raw = json.loads(updates_file.read_text())
1027
1200
  now = time.time()
@@ -1059,10 +1232,14 @@ class CellCogClient:
1059
1232
  f"Chat ID: {chat_id}",
1060
1233
  ])
1061
1234
 
1235
+ # The chat is STILL RUNNING server-side. status="timeout" historically read
1236
+ # as failure and misfired callers' retry logic (CEL-1300); status now reflects
1237
+ # the chat's real state, and timed_out=True carries "the SDK stopped waiting".
1062
1238
  return {
1063
1239
  "chat_id": chat_id,
1064
1240
  "is_operating": True,
1065
- "status": "timeout",
1241
+ "status": "operating",
1242
+ "timed_out": True,
1066
1243
  "message": "\n".join(lines),
1067
1244
  }
1068
1245
 
@@ -1555,9 +1732,15 @@ class CellCogClient:
1555
1732
 
1556
1733
  # ==================== Daemon Management ====================
1557
1734
 
1735
+ # Process-wide warn-once / give-up flags (class-level by design): a stale
1736
+ # heartbeat or an unstartable daemon would otherwise print on EVERY SDK call
1737
+ # in headless environments (CEL-1300 item 7).
1738
+ _heartbeat_warned = False
1739
+ _daemon_start_failures = 0
1740
+
1558
1741
  def _has_tracked_chats(self) -> bool:
1559
1742
  """Check if there are any tracked chats on disk."""
1560
- tracked_dir = Path("~/.cellcog/tracked_chats").expanduser()
1743
+ tracked_dir = state_path("tracked_chats")
1561
1744
  if not tracked_dir.exists():
1562
1745
  return False
1563
1746
  return any(tracked_dir.glob("*.json"))
@@ -1580,11 +1763,14 @@ class CellCogClient:
1580
1763
  except (ValueError, ProcessLookupError, PermissionError):
1581
1764
  pass
1582
1765
  finally:
1583
- # Clean up PID file
1584
- try:
1585
- self._daemon_pid_file.unlink(missing_ok=True)
1586
- except Exception:
1587
- pass
1766
+ # Clean up PID + daemon health files. Leaving a stale heartbeat behind
1767
+ # made every subsequent SDK call re-warn "heartbeat stale" and re-kill
1768
+ # forever with the same age (CEL-1300 item 7).
1769
+ for stale in (self._daemon_pid_file, state_path("daemon.heartbeat"), state_path("daemon.version")):
1770
+ try:
1771
+ stale.unlink(missing_ok=True)
1772
+ except Exception:
1773
+ pass
1588
1774
 
1589
1775
  return True
1590
1776
 
@@ -1616,7 +1802,18 @@ class CellCogClient:
1616
1802
  return self._start_daemon()
1617
1803
  return True
1618
1804
 
1619
- return self._start_daemon()
1805
+ if CellCogClient._daemon_start_failures >= 2:
1806
+ return False # daemon can't start in this environment — poll-only mode
1807
+ started = self._start_daemon()
1808
+ if not started:
1809
+ CellCogClient._daemon_start_failures += 1
1810
+ if CellCogClient._daemon_start_failures == 2:
1811
+ print(
1812
+ "Note: CellCog daemon could not be started twice; continuing in "
1813
+ "poll-only mode (create/status/history/wait still work via HTTP).",
1814
+ file=sys.stderr,
1815
+ )
1816
+ return started
1620
1817
  finally:
1621
1818
  self._daemon_check_in_progress = False
1622
1819
 
@@ -1631,7 +1828,7 @@ class CellCogClient:
1631
1828
  Returns:
1632
1829
  True if daemon should be restarted with current SDK
1633
1830
  """
1634
- version_file = Path("~/.cellcog/daemon.version").expanduser()
1831
+ version_file = state_path("daemon.version")
1635
1832
  if not version_file.exists():
1636
1833
  return True # No version file = old daemon pre-dating this feature
1637
1834
  try:
@@ -1660,17 +1857,20 @@ class CellCogClient:
1660
1857
 
1661
1858
  # Heartbeat check: daemon writes timestamp every 30s.
1662
1859
  # If stale (>120s), daemon is frozen (e.g., stuck after sleep).
1663
- heartbeat_file = Path("~/.cellcog/daemon.heartbeat").expanduser()
1860
+ heartbeat_file = state_path("daemon.heartbeat")
1664
1861
  if heartbeat_file.exists():
1665
1862
  try:
1666
1863
  heartbeat_time = float(heartbeat_file.read_text().strip())
1667
1864
  age = time.time() - heartbeat_time
1668
1865
  if age > 120:
1669
- print(
1670
- f"Warning: Daemon heartbeat stale ({age:.0f}s old). "
1671
- f"Killing and restarting...",
1672
- file=sys.stderr
1673
- )
1866
+ if not CellCogClient._heartbeat_warned:
1867
+ print(
1868
+ f"Warning: Daemon heartbeat stale ({age:.0f}s old). "
1869
+ f"Killing and restarting... (further daemon-health "
1870
+ f"warnings suppressed for this process)",
1871
+ file=sys.stderr
1872
+ )
1873
+ CellCogClient._heartbeat_warned = True
1674
1874
  self._kill_daemon_if_running()
1675
1875
  return False
1676
1876
  except (ValueError, IOError):
@@ -1697,6 +1897,9 @@ class CellCogClient:
1697
1897
  self.config.api_key,
1698
1898
  self.config.api_base_url
1699
1899
  ]
1900
+ # Pin the daemon to the SAME state dir the client resolved (matters when
1901
+ # the client fell back to a tmp/override dir — both sides must agree).
1902
+ daemon_env = {**os.environ, "CELLCOG_STATE_DIR": str(state_dir())}
1700
1903
 
1701
1904
  # Start detached process
1702
1905
  if sys.platform == "win32":
@@ -1706,6 +1909,7 @@ class CellCogClient:
1706
1909
  creationflags=subprocess.CREATE_NEW_PROCESS_GROUP,
1707
1910
  stdout=subprocess.DEVNULL,
1708
1911
  stderr=subprocess.DEVNULL,
1912
+ env=daemon_env,
1709
1913
  )
1710
1914
  else:
1711
1915
  # Unix: start new session
@@ -1714,6 +1918,7 @@ class CellCogClient:
1714
1918
  start_new_session=True,
1715
1919
  stdout=subprocess.DEVNULL,
1716
1920
  stderr=subprocess.DEVNULL,
1921
+ env=daemon_env,
1717
1922
  )
1718
1923
 
1719
1924
  # Wait briefly for daemon to start
@@ -1850,7 +2055,7 @@ class CellCogClient:
1850
2055
  tracking_file.unlink(missing_ok=True)
1851
2056
 
1852
2057
  # Remove seen indices (no longer relevant)
1853
- seen_dir = Path("~/.cellcog/chats/{}/{}".format(chat_id, ".seen_indices")).expanduser()
2058
+ seen_dir = state_path("chats", chat_id, ".seen_indices")
1854
2059
  if seen_dir.exists():
1855
2060
  shutil.rmtree(seen_dir, ignore_errors=True)
1856
2061
 
@@ -27,6 +27,7 @@ import requests
27
27
 
28
28
  from .delivery import deliver_to_all_listeners, get_gateway_auth, send_to_session
29
29
  from .state import Listener, StateManager, TrackedChat
30
+ from ..state_paths import state_path
30
31
 
31
32
  log = logging.getLogger(__name__)
32
33
 
@@ -114,7 +115,7 @@ class CellCogDaemon:
114
115
 
115
116
  # Resilience: heartbeat file for client-side daemon health checks
116
117
  self.heartbeat_task: Optional[asyncio.Task] = None
117
- self._heartbeat_file = Path("~/.cellcog/daemon.heartbeat").expanduser()
118
+ self._heartbeat_file = state_path("daemon.heartbeat")
118
119
  self._heartbeat_interval = 30 # seconds
119
120
 
120
121
  # Resilience: max tracking age before forced re-verify (24 hours)
@@ -1335,7 +1336,7 @@ def run_daemon():
1335
1336
  If no arguments provided, loads from ~/.openclaw/cellcog.json
1336
1337
  """
1337
1338
  # Setup logging
1338
- log_file = Path("~/.cellcog/daemon.log").expanduser()
1339
+ log_file = state_path("daemon.log")
1339
1340
  log_file.parent.mkdir(parents=True, exist_ok=True)
1340
1341
 
1341
1342
  logging.basicConfig(
@@ -1378,7 +1379,7 @@ def run_daemon():
1378
1379
  loop.add_signal_handler(sig, signal_handler)
1379
1380
 
1380
1381
  # Write PID file
1381
- pid_file = Path("~/.cellcog/daemon.pid").expanduser()
1382
+ pid_file = state_path("daemon.pid")
1382
1383
  pid_file.parent.mkdir(parents=True, exist_ok=True)
1383
1384
  pid_file.write_text(str(os.getpid()))
1384
1385
  log.info(f"Daemon PID: {os.getpid()}")
@@ -1386,7 +1387,7 @@ def run_daemon():
1386
1387
  # Write version file for client-side stale daemon detection
1387
1388
  from .. import __version__
1388
1389
 
1389
- version_file = Path("~/.cellcog/daemon.version").expanduser()
1390
+ version_file = state_path("daemon.version")
1390
1391
  version_file.write_text(__version__)
1391
1392
  log.info(f"Daemon version: {__version__}")
1392
1393
 
@@ -11,6 +11,8 @@ from datetime import datetime, timezone
11
11
  from pathlib import Path
12
12
  from typing import Optional
13
13
 
14
+ from ..state_paths import state_dir
15
+
14
16
 
15
17
  @dataclass
16
18
  class Listener:
@@ -134,7 +136,7 @@ class StateManager:
134
136
  Args:
135
137
  base_dir: Base directory for state files. Defaults to ~/.cellcog
136
138
  """
137
- self.base_dir = base_dir or Path("~/.cellcog").expanduser()
139
+ self.base_dir = base_dir or state_dir()
138
140
  self.tracked_dir = self.base_dir / "tracked_chats"
139
141
  self.chats_dir = self.base_dir / "chats"
140
142
 
@@ -7,30 +7,49 @@ from cellcog import CellCogClient
7
7
  client = CellCogClient(agent_provider="openclaw")
8
8
  ```
9
9
 
10
+ `agent_provider` (required) identifies the agent framework calling CellCog — lowercase
11
+ letters/numbers/hyphens. Common values: `"openclaw"`, `"cursor"`, `"claude-code"`,
12
+ `"codex"`, `"aider"`, `"windsurf"`, `"script"` (standalone scripts), `"cellcog"`
13
+ (CellCog's own agents orchestrating sub-chats).
14
+
15
+ **State directory:** all SDK state (tracked chats, downloads, daemon files) lives under
16
+ `~/.cellcog` by default. Set `CELLCOG_STATE_DIR` to override. In headless/container
17
+ environments where the home directory isn't writable (e.g. `HOME=/`), the SDK falls back
18
+ to a temp directory automatically instead of crashing.
19
+
10
20
  ---
11
21
 
12
22
  ## Chat Operations
13
23
 
14
24
  ### create_chat()
15
25
 
16
- Create a new CellCog task. Two delivery modes:
26
+ Create a new CellCog task. Three delivery modes:
17
27
  - `wait_for_completion` (default): Blocks until done. Works with any agent.
28
+ - `send_only`: Fire-and-forget. Returns the moment the chat is accepted (`status="accepted"`);
29
+ poll `get_status()` / `wait_for_completion()` yourself. Best for orchestrating several chats.
18
30
  - `notify_on_completion`: Returns immediately. OpenClaw only.
19
31
 
20
32
  ```python
21
33
  result = client.create_chat(
22
34
  prompt: str, # Task description (required)
23
35
  task_label: str = "", # Human-readable label
24
- delivery: str = "wait_for_completion",# "wait_for_completion" or "notify_on_completion"
36
+ delivery: str = "wait_for_completion",# "wait_for_completion" | "send_only" | "notify_on_completion"
25
37
  timeout: int = 1800, # Max wait seconds (wait mode only)
26
38
  notify_session_key: str = None, # OpenClaw session key (notify mode only)
27
39
  gateway_url: str = None, # OpenClaw Gateway URL (notify mode only)
28
40
  chat_mode: str = "agent", # "agent", "creative", "team" (legacy names still work)
29
- chat_tier: str = None, # "flash" | "core" | "max" (None = mode default; creative has no flash)
41
+ chat_tier: str = None, # "flash" | "core" | "max". Agent mode + None -> SDK default:
42
+ # "flash" ("max" when enable_cowork=True — coding needs depth).
43
+ # Other modes + None -> server default. Creative has no flash.
30
44
  project_id: str = None, # Project context (see project-management-cellcog)
31
45
  agent_role_id: str = None, # Specialized role (requires project_id)
32
46
  enable_cowork: bool = False, # Direct machine access (see pair-programming-cellcog)
33
47
  cowork_working_directory: str = None, # Working dir for co-work
48
+ enable_browse: bool = False, # Drive the user's REAL Chrome (needs Desktop + extension;
49
+ # auto-enables co-work server-side)
50
+ browser_profile_id: str = None, # profileDir from get_browser_status() (None = "Default")
51
+ enable_tools: bool = False, # The user's connected SaaS tools (Gmail, Notion, ...)
52
+ tools_selection: list = None, # Toolkit slugs to restrict to (None = ALL connected tools)
34
53
  )
35
54
  ```
36
55
 
@@ -39,10 +58,13 @@ result = client.create_chat(
39
58
  {
40
59
  "chat_id": str, # CellCog chat ID
41
60
  "is_operating": bool, # True = still working, False = done
42
- "status": str, # "completed" | "tracking" | "timeout"
61
+ "status": str, # "completed" | "tracking" | "accepted" | "operating"
43
62
  "message": str, # THE printable message — always print in full
44
63
  }
45
64
  ```
65
+ A wait that gives up returns `status="operating"` with `timed_out=True` — the chat is
66
+ STILL running server-side; resume with `wait_for_completion()`. `"timeout"` is no longer
67
+ returned (it read as failure and misfired retry logic).
46
68
 
47
69
  **Raises:**
48
70
  - `PaymentRequiredError` (402) — insufficient credits
@@ -54,12 +76,16 @@ result = client.create_chat(
54
76
 
55
77
  Continue an existing conversation. Same delivery modes as `create_chat()`.
56
78
 
79
+ ⚠️ The default BLOCKS until the chat's entire next run completes. When relaying messages
80
+ to several chats, pass `delivery="send_only"` — otherwise the first send hangs your
81
+ script for that chat's whole run.
82
+
57
83
  ```python
58
84
  result = client.send_message(
59
85
  chat_id: str, # Existing chat (required)
60
86
  message: str, # Follow-up message (required)
61
87
  task_label: str = None, # Label for this message
62
- delivery: str = "wait_for_completion",# "wait_for_completion" or "notify_on_completion"
88
+ delivery: str = "wait_for_completion",# "wait_for_completion" | "send_only" | "notify_on_completion"
63
89
  timeout: int = 1800, # Max wait seconds (wait mode only)
64
90
  notify_session_key: str = None, # OpenClaw session key (notify mode only)
65
91
  gateway_url: str = None, # OpenClaw Gateway URL (notify mode only)
@@ -81,7 +107,7 @@ completion = client.wait_for_completion(
81
107
 
82
108
  **Returns:** Same `{chat_id, is_operating, status, message}` shape.
83
109
  - `status: "completed"` — chat finished, `message` contains full results
84
- - `status: "timeout"` — still working, `message` contains progress
110
+ - `status: "operating"` + `timed_out: True` — still working server-side, `message` contains progress; resume with another `wait_for_completion()`
85
111
 
86
112
  ### get_history()
87
113
 
@@ -104,7 +130,50 @@ Quick status check (no message content).
104
130
  status = client.get_status(chat_id: str)
105
131
  ```
106
132
 
107
- **Returns:** `{"is_operating": bool, "status": str, "name": str, ...}`
133
+ **Returns:** `{"is_operating": bool, "status": str, "name": str, "latest_update": str | None, ...}`
134
+
135
+ `latest_update` carries the agent's most recent progress one-liner — poll it for live
136
+ orchestration dashboards. `None` until the agent posts its first update of a run.
137
+
138
+ ### get_browser_status()
139
+
140
+ Browse availability on the user's machine — populate a profile picker from this.
141
+
142
+ ```python
143
+ status = client.get_browser_status()
144
+ # {
145
+ # "extension_installed": bool,
146
+ # "chrome_running": bool,
147
+ # "active_profile": {...} | None,
148
+ # "available_profiles": [
149
+ # {"profileDir": "Default", "profileName": "Nitish", "hostedDomain": None,
150
+ # "hasExtension": True, "connected": True}, ...
151
+ # ],
152
+ # "agent_tab_count": int,
153
+ # }
154
+ ```
155
+
156
+ Pass a profile's `profileDir` as `browser_profile_id` in `create_chat(enable_browse=True)`.
157
+
158
+ ### list_toolkits()
159
+
160
+ The SaaS toolkit catalog with the user's connection state — populate a tool picker from this.
161
+
162
+ ```python
163
+ toolkits = client.list_toolkits(connected_only=True) # only what the user connected
164
+ # [{"slug": "gmail", "name": "Gmail", "connection": {...}, ...}, ...]
165
+ ```
166
+
167
+ Pass toolkit `slug` values as `tools_selection` in `create_chat(enable_tools=True)`.
168
+
169
+ ### list_toolkit_tools()
170
+
171
+ The individual tools a toolkit carries (for showing what enabling it unlocks).
172
+ `tools_selection` itself takes TOOLKIT slugs, not tool slugs.
173
+
174
+ ```python
175
+ tools = client.list_toolkit_tools("gmail")
176
+ ```
108
177
 
109
178
  ### delete_chat()
110
179
 
@@ -14,6 +14,7 @@ import requests
14
14
 
15
15
  from .config import Config
16
16
  from .exceptions import FileDownloadError, FileUploadError
17
+ from .state_paths import state_path
17
18
 
18
19
 
19
20
  class FileProcessor:
@@ -36,7 +37,7 @@ class FileProcessor:
36
37
 
37
38
  def __init__(self, config: Config):
38
39
  self.config = config
39
- self.default_download_dir = Path("~/.cellcog/chats").expanduser()
40
+ self.default_download_dir = state_path("chats")
40
41
 
41
42
  def transform_outgoing(self, message: str) -> tuple[str, list]:
42
43
  """
@@ -16,6 +16,7 @@ from .message_formatter import (
16
16
  format_messages_for_delivery,
17
17
  session_key_to_filename,
18
18
  )
19
+ from .state_paths import state_path
19
20
 
20
21
  if TYPE_CHECKING:
21
22
  from .config import Config
@@ -63,7 +64,7 @@ class MessageProcessor:
63
64
  """
64
65
  self.config = config
65
66
  self.files = file_processor
66
- self.chats_dir = Path("~/.cellcog/chats").expanduser()
67
+ self.chats_dir = state_path("chats")
67
68
 
68
69
  def process_for_delivery(
69
70
  self,
@@ -0,0 +1,83 @@
1
+ """
2
+ CellCog SDK state-directory resolution (CEL-1300 item 1).
3
+
4
+ All SDK state (tracked chats, downloaded files, daemon pid/heartbeat/version/log,
5
+ seen indices) lives under ONE base directory, resolved once per process:
6
+
7
+ 1. $CELLCOG_STATE_DIR — explicit override, always wins.
8
+ 2. ~/.cellcog — the default, when the home directory is usable and writable.
9
+ 3. $TMPDIR/cellcog-state-{uid} — fallback for headless/container environments
10
+ where HOME is unset, '/', or not writable (e.g. Docker with HOME=/). A
11
+ one-line note is printed to stderr the first time the fallback engages,
12
+ naming CELLCOG_STATE_DIR as the override.
13
+
14
+ Historically the SDK hardcoded Path("~/.cellcog").expanduser() everywhere, so a
15
+ container with HOME=/ crashed the CellCogClient constructor with a raw
16
+ PermissionError before any API call. Every path helper now routes through
17
+ state_dir()/state_path() — never hardcode ~/.cellcog anywhere else.
18
+ """
19
+
20
+ import os
21
+ import sys
22
+ import tempfile
23
+ from pathlib import Path
24
+ from typing import Optional
25
+
26
+ _resolved: Optional[Path] = None
27
+ _fallback_noted = False
28
+
29
+
30
+ def _is_dir_usable(path: Path) -> bool:
31
+ """True if `path` exists (or can be created) and is writable."""
32
+ try:
33
+ path.mkdir(parents=True, exist_ok=True)
34
+ probe = path / ".cellcog_write_probe"
35
+ probe.touch()
36
+ probe.unlink(missing_ok=True)
37
+ return True
38
+ except Exception:
39
+ return False
40
+
41
+
42
+ def state_dir() -> Path:
43
+ """Resolve (once per process) and return the SDK state base directory."""
44
+ global _resolved, _fallback_noted
45
+ if _resolved is not None:
46
+ return _resolved
47
+
48
+ override = os.environ.get("CELLCOG_STATE_DIR")
49
+ if override:
50
+ candidate = Path(override).expanduser()
51
+ if _is_dir_usable(candidate):
52
+ _resolved = candidate
53
+ return _resolved
54
+ print(
55
+ f"Warning: CELLCOG_STATE_DIR={override!r} is not writable; "
56
+ "falling back to default state-dir resolution.",
57
+ file=sys.stderr,
58
+ )
59
+
60
+ home_candidate = Path("~/.cellcog").expanduser()
61
+ # Guard the degenerate expansion ('/.cellcog' when HOME is '/' or unset):
62
+ # even where root could write it, polluting the fs root is never the intent.
63
+ if str(home_candidate) != "/.cellcog" and _is_dir_usable(home_candidate):
64
+ _resolved = home_candidate
65
+ return _resolved
66
+
67
+ uid = os.getuid() if hasattr(os, "getuid") else "user"
68
+ fallback = Path(tempfile.gettempdir()) / f"cellcog-state-{uid}"
69
+ _is_dir_usable(fallback) # best-effort create
70
+ if not _fallback_noted:
71
+ print(
72
+ f"Note: using {fallback} for CellCog SDK state (home directory not "
73
+ "writable). Set CELLCOG_STATE_DIR to choose a persistent location.",
74
+ file=sys.stderr,
75
+ )
76
+ _fallback_noted = True
77
+ _resolved = fallback
78
+ return _resolved
79
+
80
+
81
+ def state_path(*parts: str) -> Path:
82
+ """A path under the state dir, e.g. state_path('chats') or state_path('daemon.pid')."""
83
+ return state_dir().joinpath(*parts)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "cellcog"
7
- version = "2.3.0"
7
+ version = "2.4.0"
8
8
  description = "Python SDK for CellCog - Any-to-Any AI for agents. Fire-and-forget pattern with WebSocket notifications."
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes