overcode 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 (43) hide show
  1. overcode/__init__.py +5 -0
  2. overcode/cli.py +812 -0
  3. overcode/config.py +72 -0
  4. overcode/daemon.py +1184 -0
  5. overcode/daemon_claude_skill.md +180 -0
  6. overcode/daemon_state.py +113 -0
  7. overcode/data_export.py +257 -0
  8. overcode/dependency_check.py +227 -0
  9. overcode/exceptions.py +219 -0
  10. overcode/history_reader.py +448 -0
  11. overcode/implementations.py +214 -0
  12. overcode/interfaces.py +49 -0
  13. overcode/launcher.py +434 -0
  14. overcode/logging_config.py +193 -0
  15. overcode/mocks.py +152 -0
  16. overcode/monitor_daemon.py +808 -0
  17. overcode/monitor_daemon_state.py +358 -0
  18. overcode/pid_utils.py +225 -0
  19. overcode/presence_logger.py +454 -0
  20. overcode/protocols.py +143 -0
  21. overcode/session_manager.py +606 -0
  22. overcode/settings.py +412 -0
  23. overcode/standing_instructions.py +276 -0
  24. overcode/status_constants.py +190 -0
  25. overcode/status_detector.py +339 -0
  26. overcode/status_history.py +164 -0
  27. overcode/status_patterns.py +264 -0
  28. overcode/summarizer_client.py +136 -0
  29. overcode/summarizer_component.py +312 -0
  30. overcode/supervisor_daemon.py +1000 -0
  31. overcode/supervisor_layout.sh +50 -0
  32. overcode/tmux_manager.py +228 -0
  33. overcode/tui.py +2549 -0
  34. overcode/tui_helpers.py +495 -0
  35. overcode/web_api.py +279 -0
  36. overcode/web_server.py +138 -0
  37. overcode/web_templates.py +563 -0
  38. overcode-0.1.0.dist-info/METADATA +87 -0
  39. overcode-0.1.0.dist-info/RECORD +43 -0
  40. overcode-0.1.0.dist-info/WHEEL +5 -0
  41. overcode-0.1.0.dist-info/entry_points.txt +2 -0
  42. overcode-0.1.0.dist-info/licenses/LICENSE +21 -0
  43. overcode-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,606 @@
1
+ """
2
+ Session state management for Overcode.
3
+ """
4
+
5
+ import json
6
+ import os
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+ from typing import Callable, Dict, List, Optional
10
+ from dataclasses import dataclass, asdict, field, fields
11
+ import uuid
12
+ import time
13
+
14
+ from .exceptions import StateWriteError
15
+
16
+ try:
17
+ import fcntl
18
+ HAS_FCNTL = True
19
+ except ImportError:
20
+ # Windows doesn't have fcntl
21
+ HAS_FCNTL = False
22
+
23
+
24
+ @dataclass
25
+ class SessionStats:
26
+ """Runtime statistics for a Claude session"""
27
+ interaction_count: int = 0
28
+ estimated_cost_usd: float = 0.0
29
+ total_tokens: int = 0
30
+ operation_times: List[float] = field(default_factory=list) # seconds per operation
31
+ steers_count: int = 0 # number of overcode interventions
32
+ last_activity: Optional[str] = None # ISO timestamp
33
+ current_task: str = "Initializing..." # one-sentence description
34
+
35
+ # Token breakdown (persisted from Claude Code history)
36
+ input_tokens: int = 0
37
+ output_tokens: int = 0
38
+ cache_creation_tokens: int = 0
39
+ cache_read_tokens: int = 0
40
+ last_stats_update: Optional[str] = None # ISO timestamp of last stats sync
41
+
42
+ # State tracking
43
+ current_state: str = "running" # running, no_instructions, waiting_supervisor, waiting_user
44
+ state_since: Optional[str] = None # ISO timestamp when current state started
45
+ green_time_seconds: float = 0.0 # time spent in "running" state
46
+ non_green_time_seconds: float = 0.0 # time spent in non-running states
47
+ last_time_accumulation: Optional[str] = None # ISO timestamp when times were last accumulated
48
+
49
+ def to_dict(self) -> dict:
50
+ return asdict(self)
51
+
52
+ @classmethod
53
+ def from_dict(cls, data: dict) -> 'SessionStats':
54
+ """Create SessionStats from dict, handling unknown/invalid fields gracefully."""
55
+ # Get valid field names from the dataclass
56
+ valid_fields = {f.name for f in fields(cls)}
57
+ # Filter to only known fields to avoid TypeError on unknown keys
58
+ filtered = {k: v for k, v in data.items() if k in valid_fields}
59
+ try:
60
+ return cls(**filtered)
61
+ except TypeError:
62
+ # If still failing (wrong types), return defaults
63
+ return cls()
64
+
65
+
66
+ @dataclass
67
+ class Session:
68
+ """Represents a Claude session"""
69
+ id: str
70
+ name: str
71
+ tmux_session: str
72
+ tmux_window: int
73
+ command: List[str]
74
+ start_directory: Optional[str]
75
+ start_time: str
76
+
77
+ # Git context
78
+ repo_name: Optional[str] = None
79
+ branch: Optional[str] = None
80
+
81
+ # Management
82
+ status: str = "running"
83
+ permissiveness_mode: str = "normal" # normal, permissive, bypass
84
+ standing_instructions: str = "" # e.g., "keep herding it on to completion"
85
+ standing_instructions_preset: Optional[str] = None # preset name if using library preset
86
+ standing_orders_complete: bool = False # True when supervisor marks orders as done
87
+
88
+ # Statistics
89
+ stats: SessionStats = field(default_factory=SessionStats)
90
+
91
+ def to_dict(self) -> dict:
92
+ data = asdict(self)
93
+ # Convert stats to dict
94
+ data['stats'] = self.stats.to_dict()
95
+ return data
96
+
97
+ @classmethod
98
+ def from_dict(cls, data: dict) -> Optional['Session']:
99
+ """Create Session from dict, handling unknown/invalid fields gracefully.
100
+
101
+ Returns None if required fields are missing or data is corrupt.
102
+ """
103
+ # Required fields that must be present
104
+ required = {'id', 'name', 'tmux_session', 'tmux_window', 'command', 'start_directory', 'start_time'}
105
+ if not all(k in data for k in required):
106
+ return None
107
+
108
+ # Handle stats separately
109
+ if 'stats' in data and isinstance(data['stats'], dict):
110
+ data['stats'] = SessionStats.from_dict(data['stats'])
111
+ elif 'stats' not in data:
112
+ data['stats'] = SessionStats()
113
+
114
+ # Get valid field names and filter unknown keys
115
+ valid_fields = {f.name for f in fields(cls)}
116
+ filtered = {k: v for k, v in data.items() if k in valid_fields}
117
+
118
+ try:
119
+ return cls(**filtered)
120
+ except TypeError:
121
+ # Type mismatch or other issue - session is corrupt
122
+ return None
123
+
124
+
125
+ class SessionManager:
126
+ """Manages session state persistence.
127
+
128
+ For testing, pass a custom state_dir (temp directory) and skip_git_detection=True.
129
+ """
130
+
131
+ def __init__(self, state_dir: Optional[Path] = None, skip_git_detection: bool = False):
132
+ """Initialize the session manager.
133
+
134
+ Args:
135
+ state_dir: Directory for state files (defaults to ~/.overcode/sessions)
136
+ skip_git_detection: If True, skip git repo/branch detection (for testing)
137
+ """
138
+ if state_dir is None:
139
+ # Support OVERCODE_STATE_DIR env var for testing
140
+ env_state_dir = os.environ.get("OVERCODE_STATE_DIR")
141
+ if env_state_dir:
142
+ state_dir = Path(env_state_dir) / "sessions"
143
+ else:
144
+ state_dir = Path.home() / ".overcode" / "sessions"
145
+ self.state_dir = Path(state_dir)
146
+ self.state_dir.mkdir(parents=True, exist_ok=True)
147
+ self.state_file = self.state_dir / "sessions.json"
148
+ self.archive_file = self.state_dir / "archive.json"
149
+ self._skip_git_detection = skip_git_detection
150
+
151
+ def _load_state(self) -> Dict[str, dict]:
152
+ """Load all sessions from state file with file locking.
153
+
154
+ On JSON corruption, attempts to restore from backup automatically.
155
+ """
156
+ if not self.state_file.exists():
157
+ return {}
158
+
159
+ max_retries = 5
160
+ retry_delay = 0.1
161
+
162
+ for attempt in range(max_retries):
163
+ try:
164
+ with open(self.state_file, 'r') as f:
165
+ if HAS_FCNTL:
166
+ # Acquire shared lock for reading
167
+ fcntl.flock(f.fileno(), fcntl.LOCK_SH)
168
+ try:
169
+ return json.load(f)
170
+ finally:
171
+ fcntl.flock(f.fileno(), fcntl.LOCK_UN)
172
+ else:
173
+ # No locking on Windows
174
+ return json.load(f)
175
+ except json.JSONDecodeError as e:
176
+ if attempt < max_retries - 1:
177
+ time.sleep(retry_delay)
178
+ continue
179
+ # JSON corruption detected - try to restore from backup
180
+ print(f"Warning: State file corrupted: {e}")
181
+ if self.restore_from_backup():
182
+ print("Restored sessions from backup file")
183
+ # Try loading the restored file
184
+ try:
185
+ with open(self.state_file, 'r') as f:
186
+ return json.load(f)
187
+ except json.JSONDecodeError:
188
+ print("Warning: Backup file also corrupted, starting fresh")
189
+ return {}
190
+ else:
191
+ print("Warning: No backup available, starting fresh")
192
+ return {}
193
+ except IOError as e:
194
+ if attempt < max_retries - 1:
195
+ time.sleep(retry_delay)
196
+ continue
197
+ print(f"Warning: Could not load state file: {e}")
198
+ return {}
199
+
200
+ return {}
201
+
202
+ def _backup_state(self) -> None:
203
+ """Create a backup of the current state file before writing."""
204
+ if not self.state_file.exists():
205
+ return
206
+
207
+ backup_file = self.state_file.with_suffix('.json.bak')
208
+ try:
209
+ import shutil
210
+ shutil.copy2(self.state_file, backup_file)
211
+ except (OSError, IOError):
212
+ # Backup is best-effort, don't fail the write
213
+ pass
214
+
215
+ def restore_from_backup(self) -> bool:
216
+ """Restore state from backup file if available.
217
+
218
+ Returns:
219
+ True if backup was restored, False otherwise
220
+ """
221
+ backup_file = self.state_file.with_suffix('.json.bak')
222
+ if not backup_file.exists():
223
+ return False
224
+
225
+ try:
226
+ import shutil
227
+ shutil.copy2(backup_file, self.state_file)
228
+ return True
229
+ except (OSError, IOError):
230
+ return False
231
+
232
+ def _save_state(self, state: Dict[str, dict]):
233
+ """Save all sessions to state file with file locking and atomic writes"""
234
+ import threading
235
+ max_retries = 5
236
+ retry_delay = 0.1
237
+
238
+ # Create backup before writing
239
+ self._backup_state()
240
+
241
+ for attempt in range(max_retries):
242
+ try:
243
+ if HAS_FCNTL:
244
+ # Use atomic write with exclusive lock
245
+ # Use unique temp file name to avoid race conditions
246
+ temp_suffix = f'.tmp.{os.getpid()}.{threading.get_ident()}'
247
+ temp_file = self.state_file.with_suffix(temp_suffix)
248
+ try:
249
+ with open(temp_file, 'w') as f:
250
+ fcntl.flock(f.fileno(), fcntl.LOCK_EX)
251
+ try:
252
+ json.dump(state, f, indent=2)
253
+ f.flush()
254
+ os.fsync(f.fileno())
255
+ finally:
256
+ fcntl.flock(f.fileno(), fcntl.LOCK_UN)
257
+ # Atomic rename
258
+ temp_file.rename(self.state_file)
259
+ finally:
260
+ # Clean up temp file if rename failed
261
+ if temp_file.exists():
262
+ temp_file.unlink()
263
+ else:
264
+ # No locking on Windows, just write
265
+ with open(self.state_file, 'w') as f:
266
+ json.dump(state, f, indent=2)
267
+ return
268
+ except (IOError, OSError) as e:
269
+ if attempt < max_retries - 1:
270
+ time.sleep(retry_delay)
271
+ continue
272
+ raise StateWriteError(f"Failed to save state file after {max_retries} attempts: {e}")
273
+
274
+ def _atomic_update(self, update_fn: Callable[[Dict[str, dict]], Dict[str, dict]]) -> None:
275
+ """Atomically read, modify, and write state with exclusive lock held throughout.
276
+
277
+ This prevents TOCTOU race conditions by holding the lock during the entire
278
+ read-modify-write cycle.
279
+
280
+ Args:
281
+ update_fn: Function that takes the current state dict and returns the updated state.
282
+ """
283
+ max_retries = 5
284
+ retry_delay = 0.1
285
+
286
+ for attempt in range(max_retries):
287
+ try:
288
+ if HAS_FCNTL:
289
+ # Use 'a+' to create file if missing, then seek to start
290
+ # This avoids TOCTOU race where file is created outside lock
291
+ with open(self.state_file, 'a+') as f:
292
+ fcntl.flock(f.fileno(), fcntl.LOCK_EX)
293
+ try:
294
+ f.seek(0)
295
+ content = f.read()
296
+ state = json.loads(content) if content.strip() else {}
297
+ state = update_fn(state)
298
+ f.seek(0)
299
+ f.truncate()
300
+ json.dump(state, f, indent=2)
301
+ f.flush()
302
+ os.fsync(f.fileno())
303
+ finally:
304
+ fcntl.flock(f.fileno(), fcntl.LOCK_UN)
305
+ else:
306
+ # No locking on Windows - fall back to read/modify/write
307
+ state = self._load_state()
308
+ state = update_fn(state)
309
+ self._save_state(state)
310
+ return
311
+ except (IOError, OSError, json.JSONDecodeError) as e:
312
+ if attempt < max_retries - 1:
313
+ time.sleep(retry_delay)
314
+ continue
315
+ raise StateWriteError(f"Failed to update state file after {max_retries} attempts: {e}")
316
+
317
+ def _detect_git_context(self, directory: Optional[str]) -> tuple[Optional[str], Optional[str]]:
318
+ """Detect git repo and branch from directory"""
319
+ if not directory:
320
+ return None, None
321
+
322
+ # Check directory exists
323
+ if not os.path.isdir(directory):
324
+ return None, None
325
+
326
+ try:
327
+ import subprocess
328
+
329
+ # Get repo name
330
+ result = subprocess.run(
331
+ ["git", "rev-parse", "--show-toplevel"],
332
+ cwd=directory,
333
+ capture_output=True,
334
+ text=True,
335
+ timeout=2
336
+ )
337
+ repo_path = result.stdout.strip() if result.returncode == 0 else None
338
+ repo_name = Path(repo_path).name if repo_path else None
339
+
340
+ # Get branch
341
+ result = subprocess.run(
342
+ ["git", "branch", "--show-current"],
343
+ cwd=directory,
344
+ capture_output=True,
345
+ text=True,
346
+ timeout=2
347
+ )
348
+ branch = result.stdout.strip() if result.returncode == 0 else None
349
+
350
+ return repo_name, branch
351
+ except subprocess.TimeoutExpired:
352
+ print(f"Warning: Git command timed out in {directory}")
353
+ return None, None
354
+ except subprocess.CalledProcessError as e:
355
+ print(f"Warning: Git command failed: {e}")
356
+ return None, None
357
+ except (OSError, IOError) as e:
358
+ print(f"Warning: Could not detect git context: {e}")
359
+ return None, None
360
+
361
+ def refresh_git_context(self, session_id: str) -> bool:
362
+ """Refresh git repo/branch info for a session.
363
+
364
+ Detects current branch from the session's start_directory and
365
+ updates the session if it has changed.
366
+
367
+ Returns:
368
+ True if git context was updated, False otherwise
369
+ """
370
+ session = self.get_session(session_id)
371
+ if not session or not session.start_directory:
372
+ return False
373
+
374
+ repo_name, branch = self._detect_git_context(session.start_directory)
375
+
376
+ # Only update if something changed
377
+ if repo_name != session.repo_name or branch != session.branch:
378
+ self.update_session(
379
+ session_id,
380
+ repo_name=repo_name,
381
+ branch=branch
382
+ )
383
+ return True
384
+ return False
385
+
386
+ def create_session(self, name: str, tmux_session: str, tmux_window: int,
387
+ command: List[str], start_directory: Optional[str] = None,
388
+ standing_instructions: str = "",
389
+ permissiveness_mode: str = "normal") -> Session:
390
+ """Create and register a new session.
391
+
392
+ Args:
393
+ name: Session name
394
+ tmux_session: Name of the tmux session
395
+ tmux_window: Tmux window index
396
+ command: Command used to start the session
397
+ start_directory: Working directory for the session
398
+ standing_instructions: Initial standing instructions (e.g., from config)
399
+ permissiveness_mode: Permission mode (normal, permissive, bypass)
400
+ """
401
+ if self._skip_git_detection:
402
+ repo_name, branch = None, None
403
+ else:
404
+ repo_name, branch = self._detect_git_context(start_directory)
405
+
406
+ session = Session(
407
+ id=str(uuid.uuid4()),
408
+ name=name,
409
+ tmux_session=tmux_session,
410
+ tmux_window=tmux_window,
411
+ command=command,
412
+ start_directory=start_directory,
413
+ start_time=datetime.now().isoformat(),
414
+ repo_name=repo_name,
415
+ branch=branch,
416
+ standing_instructions=standing_instructions,
417
+ permissiveness_mode=permissiveness_mode
418
+ )
419
+
420
+ state = self._load_state()
421
+ state[session.id] = session.to_dict()
422
+ self._save_state(state)
423
+
424
+ return session
425
+
426
+ def get_session(self, session_id: str) -> Optional[Session]:
427
+ """Get a session by ID"""
428
+ state = self._load_state()
429
+ if session_id in state:
430
+ return Session.from_dict(state[session_id])
431
+ return None
432
+
433
+ def get_session_by_name(self, name: str) -> Optional[Session]:
434
+ """Get a session by name"""
435
+ state = self._load_state()
436
+ for session_data in state.values():
437
+ if session_data['name'] == name:
438
+ return Session.from_dict(session_data)
439
+ return None
440
+
441
+ def list_sessions(self) -> List[Session]:
442
+ """List all sessions (skips corrupted entries)"""
443
+ state = self._load_state()
444
+ sessions = [Session.from_dict(data) for data in state.values()]
445
+ # Filter out None (corrupted sessions)
446
+ return [s for s in sessions if s is not None]
447
+
448
+ def update_session_status(self, session_id: str, status: str):
449
+ """Update session status"""
450
+ def do_update(state: Dict[str, dict]) -> Dict[str, dict]:
451
+ if session_id in state:
452
+ state[session_id]['status'] = status
453
+ return state
454
+ self._atomic_update(do_update)
455
+
456
+ def delete_session(self, session_id: str, archive: bool = True):
457
+ """Delete a session, optionally archiving it first.
458
+
459
+ Args:
460
+ session_id: The session ID to delete
461
+ archive: If True (default), archive the session before removing
462
+ """
463
+ # Capture session data for archiving before the atomic update
464
+ archived_data = None
465
+
466
+ def do_delete(state: Dict[str, dict]) -> Dict[str, dict]:
467
+ nonlocal archived_data
468
+ if session_id in state:
469
+ if archive:
470
+ # Capture data for archiving
471
+ archived_data = state[session_id].copy()
472
+ archived_data['end_time'] = datetime.now().isoformat()
473
+ archived_data['status'] = 'archived'
474
+ del state[session_id]
475
+ return state
476
+
477
+ self._atomic_update(do_delete)
478
+
479
+ # Archive after the atomic update (separate file, separate lock)
480
+ if archived_data is not None:
481
+ self._archive_session(archived_data)
482
+
483
+ def _load_archive(self) -> Dict[str, dict]:
484
+ """Load archived sessions."""
485
+ if not self.archive_file.exists():
486
+ return {}
487
+
488
+ try:
489
+ with open(self.archive_file, 'r') as f:
490
+ if HAS_FCNTL:
491
+ fcntl.flock(f.fileno(), fcntl.LOCK_SH)
492
+ try:
493
+ return json.load(f)
494
+ finally:
495
+ fcntl.flock(f.fileno(), fcntl.LOCK_UN)
496
+ else:
497
+ return json.load(f)
498
+ except (json.JSONDecodeError, IOError):
499
+ return {}
500
+
501
+ def _save_archive(self, archive: Dict[str, dict]):
502
+ """Save archived sessions."""
503
+ import threading
504
+ if HAS_FCNTL:
505
+ temp_suffix = f'.tmp.{os.getpid()}.{threading.get_ident()}'
506
+ temp_file = self.archive_file.with_suffix(temp_suffix)
507
+ try:
508
+ with open(temp_file, 'w') as f:
509
+ fcntl.flock(f.fileno(), fcntl.LOCK_EX)
510
+ try:
511
+ json.dump(archive, f, indent=2)
512
+ f.flush()
513
+ os.fsync(f.fileno())
514
+ finally:
515
+ fcntl.flock(f.fileno(), fcntl.LOCK_UN)
516
+ temp_file.rename(self.archive_file)
517
+ finally:
518
+ if temp_file.exists():
519
+ temp_file.unlink()
520
+ else:
521
+ with open(self.archive_file, 'w') as f:
522
+ json.dump(archive, f, indent=2)
523
+
524
+ def _archive_session(self, session_data: dict):
525
+ """Add a session to the archive."""
526
+ archive = self._load_archive()
527
+ archive[session_data['id']] = session_data
528
+ self._save_archive(archive)
529
+
530
+ def list_archived_sessions(self) -> List[Session]:
531
+ """List all archived sessions (skips corrupted entries)."""
532
+ archive = self._load_archive()
533
+ sessions = []
534
+ for data in archive.values():
535
+ try:
536
+ # Handle end_time field that's not in Session dataclass
537
+ data_copy = data.copy()
538
+ end_time = data_copy.pop('end_time', None)
539
+ session = Session.from_dict(data_copy)
540
+ if session is None:
541
+ continue
542
+ # Store end_time as attribute for display
543
+ session._end_time = end_time # type: ignore
544
+ sessions.append(session)
545
+ except (KeyError, TypeError):
546
+ continue
547
+ return sessions
548
+
549
+ def get_archived_session(self, session_id: str) -> Optional[Session]:
550
+ """Get an archived session by ID."""
551
+ archive = self._load_archive()
552
+ if session_id in archive:
553
+ data = archive[session_id].copy()
554
+ end_time = data.pop('end_time', None)
555
+ session = Session.from_dict(data)
556
+ if session is None:
557
+ return None
558
+ session._end_time = end_time # type: ignore
559
+ return session
560
+ return None
561
+
562
+ def update_session(self, session_id: str, **kwargs):
563
+ """Update session fields"""
564
+ def do_update(state: Dict[str, dict]) -> Dict[str, dict]:
565
+ if session_id in state:
566
+ state[session_id].update(kwargs)
567
+ return state
568
+ self._atomic_update(do_update)
569
+
570
+ def update_stats(self, session_id: str, **stats_kwargs):
571
+ """Update session statistics"""
572
+ def do_update(state: Dict[str, dict]) -> Dict[str, dict]:
573
+ if session_id in state:
574
+ if 'stats' not in state[session_id]:
575
+ state[session_id]['stats'] = SessionStats().to_dict()
576
+ state[session_id]['stats'].update(stats_kwargs)
577
+ return state
578
+ self._atomic_update(do_update)
579
+
580
+ def set_standing_instructions(
581
+ self,
582
+ session_id: str,
583
+ instructions: str,
584
+ preset_name: Optional[str] = None
585
+ ):
586
+ """Set standing instructions for a session (resets complete flag).
587
+
588
+ Args:
589
+ session_id: The session ID
590
+ instructions: Full instruction text
591
+ preset_name: Preset name if using a library preset, None for custom
592
+ """
593
+ self.update_session(
594
+ session_id,
595
+ standing_instructions=instructions,
596
+ standing_instructions_preset=preset_name,
597
+ standing_orders_complete=False
598
+ )
599
+
600
+ def set_standing_orders_complete(self, session_id: str, complete: bool = True):
601
+ """Mark standing orders as complete or incomplete"""
602
+ self.update_session(session_id, standing_orders_complete=complete)
603
+
604
+ def set_permissiveness(self, session_id: str, mode: str):
605
+ """Set permissiveness mode (normal, permissive, strict)"""
606
+ self.update_session(session_id, permissiveness_mode=mode)