aline-ai 0.5.1__py3-none-any.whl → 0.5.4__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.
realign/commands/add.py CHANGED
@@ -251,10 +251,233 @@ Use this skill when the user wants to:
251
251
  - Send Slack updates about completed work
252
252
  """
253
253
 
254
+ # Aline Import History Sessions skill definition for Claude Code
255
+ # Installed to ~/.claude/skills/aline-import-history-sessions/SKILL.md
256
+ ALINE_IMPORT_HISTORY_SESSIONS_SKILL_MD = """---
257
+ name: aline-import-history-sessions
258
+ description: Guide users through importing Claude Code session history into Aline database. Use this for first-time setup, onboarding new users, or when users want to selectively import historical sessions. Provides interactive workflow with progress checking.
259
+ ---
260
+
261
+ # Aline Import History Sessions Skill
262
+
263
+ This skill guides users through the process of importing Claude Code session history into Aline's database. It provides an interactive, step-by-step workflow to help users discover, select, and import their historical sessions.
264
+
265
+ ## Workflow Overview
266
+
267
+ ```
268
+ Analyze Unimported Sessions → Present Summary → User Selection → Import Sessions → Verify Success → Continue or Finish
269
+ ```
270
+
271
+ ## Step-by-Step Guide
272
+
273
+ ### Step 1: Analyze Current Status
274
+
275
+ First, list all sessions to understand what hasn't been imported yet:
276
+
277
+ ```bash
278
+ aline watcher session list --detect-turns
279
+ ```
280
+
281
+ **Internal analysis (do NOT expose status terminology to user):**
282
+ - Count sessions with status `new` → these are "unimported sessions"
283
+ - Count sessions with status `partial` → these have "updates available"
284
+ - Count sessions with status `tracked` → these are "already imported"
285
+
286
+ Parse the output to extract:
287
+ - Total number of unimported sessions
288
+ - Their session IDs (use these for import, NOT index numbers)
289
+ - Project paths they belong to
290
+
291
+ ### Step 2: Present Summary to User
292
+
293
+ Present a user-friendly summary WITHOUT mentioning internal status labels:
294
+
295
+ Example:
296
+ > "I found **47 sessions** in your Claude Code history:
297
+ > - **12 sessions** haven't been imported yet
298
+ > - **3 sessions** have updates since last import
299
+ > - **32 sessions** are already fully imported
300
+ >
301
+ > The unimported sessions span these projects:
302
+ > - `/Users/you/Projects/ProjectA` (5 sessions)
303
+ > - `/Users/you/Projects/ProjectB` (7 sessions)"
304
+
305
+ ### Step 3: Ask User Import Preferences
306
+
307
+ Use `AskUserQuestion` to understand what the user wants to import:
308
+
309
+ ```json
310
+ {
311
+ "questions": [{
312
+ "header": "Import scope",
313
+ "question": "Which sessions would you like to import?",
314
+ "options": [
315
+ {"label": "All unimported (Recommended)", "description": "Import all 12 sessions that haven't been imported yet"},
316
+ {"label": "Include updates", "description": "Import unimported sessions + update 3 sessions with new content"},
317
+ {"label": "Select by project", "description": "Choose which project's sessions to import"},
318
+ {"label": "Select specific", "description": "I'll review and pick individual sessions"}
319
+ ],
320
+ "multiSelect": false
321
+ }]
322
+ }
323
+ ```
324
+
325
+ ### Step 4: Handle User Selection
326
+
327
+ #### If "All unimported":
328
+ Confirm the import with session count:
329
+ ```json
330
+ {
331
+ "questions": [{
332
+ "header": "Confirm",
333
+ "question": "Ready to import 12 sessions. Proceed?",
334
+ "options": [
335
+ {"label": "Yes, import", "description": "Start importing all unimported sessions"},
336
+ {"label": "Let me review first", "description": "Show me the session list to review"}
337
+ ],
338
+ "multiSelect": false
339
+ }]
340
+ }
341
+ ```
342
+
343
+ #### If "Select by project":
344
+ List the projects with unimported sessions and ask:
345
+ ```json
346
+ {
347
+ "questions": [{
348
+ "header": "Project",
349
+ "question": "Which project's sessions should I import?",
350
+ "options": [
351
+ {"label": "ProjectA", "description": "5 unimported sessions"},
352
+ {"label": "ProjectB", "description": "7 unimported sessions"},
353
+ {"label": "Current directory", "description": "Import sessions from the current working directory"}
354
+ ],
355
+ "multiSelect": true
356
+ }]
357
+ }
358
+ ```
359
+
360
+ #### If "Select specific":
361
+ Show the session list with details (project path, turn count, last modified) and let user specify which ones. When user provides selection, map their choice back to session IDs.
362
+
363
+ ### Step 5: Execute Import
364
+
365
+ **IMPORTANT: Always use session_id for imports, NOT index numbers.** Index numbers can change between list operations and cause wrong sessions to be imported.
366
+
367
+ ```bash
368
+ # Import by session ID (PREFERRED - always use this)
369
+ aline watcher session import abc12345-6789-...
370
+
371
+ # Import multiple by session ID
372
+ aline watcher session import abc12345,def67890,ghi11111
373
+
374
+ # With force flag (re-import already tracked)
375
+ aline watcher session import abc12345 --force
376
+
377
+ # With regenerate flag (update summaries)
378
+ aline watcher session import abc12345 --regenerate
379
+
380
+ # Synchronous import to wait for completion
381
+ aline watcher session import abc12345 --sync
382
+ ```
383
+
384
+ For importing multiple sessions, collect all the session IDs from your analysis and pass them comma-separated.
385
+
386
+ ### Step 6: Verify Import Success
387
+
388
+ After import, check the status again:
389
+
390
+ ```bash
391
+ aline watcher session list --detect-turns
392
+ ```
393
+
394
+ Verify:
395
+ - Previously unimported sessions should now show as imported
396
+ - Check for any errors in the import output
397
+ - Report success/failure count to user
398
+
399
+ ### Step 7: Ask If User Is Satisfied
400
+
401
+ ```json
402
+ {
403
+ "questions": [{
404
+ "header": "Continue?",
405
+ "question": "Successfully imported X sessions. What would you like to do next?",
406
+ "options": [
407
+ {"label": "Import more", "description": "Select additional sessions to import"},
408
+ {"label": "View imported", "description": "Show details of imported sessions"},
409
+ {"label": "Done", "description": "Finish the import process"}
410
+ ],
411
+ "multiSelect": false
412
+ }]
413
+ }
414
+ ```
415
+
416
+ If user wants to import more, loop back to Step 1.
417
+
418
+ ### Step 8: Final Summary
419
+
420
+ When the user is done, provide a summary:
421
+ - Total sessions imported in this session
422
+ - Sessions that were updated
423
+ - Any errors encountered
424
+ - Next steps: suggest `aline search` to explore their imported history
425
+
426
+ ## Command Reference
427
+
428
+ | Command | Purpose |
429
+ |---------|---------|
430
+ | `aline watcher session list` | List all discovered sessions with status |
431
+ | `aline watcher session list --detect-turns` | Include turn counts in listing |
432
+ | `aline watcher session list -p N -n M` | Paginate: page N with M items per page |
433
+ | `aline watcher session import <session_id>` | Import by session ID (recommended) |
434
+ | `aline watcher session import <id1>,<id2>` | Import multiple sessions by ID |
435
+ | `aline watcher session import <id> -f` | Force re-import |
436
+ | `aline watcher session import <id> -r` | Regenerate LLM summaries |
437
+ | `aline watcher session import <id> --sync` | Synchronous import (wait for completion) |
438
+ | `aline watcher session show <session_id>` | View details of a specific session |
439
+
440
+ ## Important: Use Session IDs, Not Index Numbers
441
+
442
+ **Always use session_id (UUID) for import operations.**
443
+
444
+ Why:
445
+ - Index numbers are assigned dynamically and can change between list commands
446
+ - Using wrong index could import unintended sessions
447
+ - Session IDs are stable and unique identifiers
448
+
449
+ Example session ID format: `e58f67bf-ebba-47bd-9371-5ef9e06697d3`
450
+
451
+ You can use UUID prefix for convenience: `e58f67bf` (first 8 characters)
452
+
453
+ ## Error Handling
454
+
455
+ - **No sessions found**: Check if Claude Code history exists at `~/.claude/projects/`. Suggest running some Claude Code sessions first.
456
+ - **Import fails**: Check disk space, database permissions at `~/.aline/db/aline.db`
457
+ - **Partial import**: Some sessions may fail due to corrupted JSONL files. Report specific errors and suggest `--force` retry.
458
+ - **Watcher not running**: If async import doesn't complete, suggest `aline watcher start` or use `--sync` flag.
459
+
460
+ ## Tips for Large Imports
461
+
462
+ - For many sessions (50+), import in batches to track progress
463
+ - Use `--sync` flag to see real-time progress for smaller batches
464
+ - Check `~/.aline/.logs/watcher.log` for detailed import logs
465
+
466
+ ## When to Use This Skill
467
+
468
+ Use this skill when:
469
+ - User is setting up Aline for the first time
470
+ - User wants to import historical Claude Code sessions
471
+ - User asks "how do I import my history?" or similar
472
+ - User wants to selectively import specific project sessions
473
+ - User needs to re-import or update existing sessions
474
+ """
475
+
254
476
  # Registry of all skills to install
255
477
  SKILLS_REGISTRY: dict[str, str] = {
256
478
  "aline": ALINE_SKILL_MD,
257
479
  "aline-share": ALINE_SHARE_SKILL_MD,
480
+ "aline-import-history-sessions": ALINE_IMPORT_HISTORY_SESSIONS_SKILL_MD,
258
481
  }
259
482
 
260
483
 
@@ -365,3 +588,83 @@ def add_skills_command(force: bool = False) -> int:
365
588
 
366
589
  return 1 if failed_skills else 0
367
590
 
591
+
592
+ def add_skills_dev_command(force: bool = False) -> int:
593
+ """Install developer skills from skill-dev/ directory.
594
+
595
+ Scans the skill-dev/ folder in the project root for SKILL.md files
596
+ and installs them to ~/.claude/skills/
597
+
598
+ This is for developer use only - skills in development that are not
599
+ yet bundled into the package.
600
+
601
+ Args:
602
+ force: Overwrite existing skills if they exist
603
+
604
+ Returns:
605
+ Exit code (0 for success, 1 for failure)
606
+ """
607
+ # Find skill-dev directory relative to this file's package location
608
+ # Go up from src/realign/commands/add.py to project root
609
+ package_root = Path(__file__).parent.parent.parent.parent
610
+ skill_dev_dir = package_root / "skill-dev"
611
+
612
+ if not skill_dev_dir.exists():
613
+ console.print(f"[red]skill-dev/ directory not found at:[/red] {skill_dev_dir}")
614
+ console.print("[dim]This command is for developer use only.[/dim]")
615
+ return 1
616
+
617
+ claude_skill_root = Path.home() / ".claude" / "skills"
618
+ installed_skills: list[str] = []
619
+ skipped_skills: list[str] = []
620
+ failed_skills: list[tuple[str, str]] = []
621
+
622
+ # Scan skill-dev for directories containing SKILL.md
623
+ for skill_dir in skill_dev_dir.iterdir():
624
+ if not skill_dir.is_dir():
625
+ continue
626
+
627
+ skill_file = skill_dir / "SKILL.md"
628
+ if not skill_file.exists():
629
+ continue
630
+
631
+ skill_name = skill_dir.name
632
+ dest_path = claude_skill_root / skill_name / "SKILL.md"
633
+
634
+ # Check if skill already exists
635
+ if dest_path.exists() and not force:
636
+ skipped_skills.append(skill_name)
637
+ continue
638
+
639
+ try:
640
+ skill_content = skill_file.read_text(encoding="utf-8")
641
+ _install_skill_to_path(claude_skill_root, skill_name, skill_content)
642
+ installed_skills.append(skill_name)
643
+ except Exception as e:
644
+ failed_skills.append((skill_name, str(e)))
645
+
646
+ if not installed_skills and not skipped_skills and not failed_skills:
647
+ console.print("[yellow]No skills found in skill-dev/[/yellow]")
648
+ console.print("[dim]Each skill should be in its own directory with a SKILL.md file.[/dim]")
649
+ return 0
650
+
651
+ # Report results
652
+ for skill_name in installed_skills:
653
+ skill_path = claude_skill_root / skill_name / "SKILL.md"
654
+ console.print(f"[green]✓[/green] Installed: [cyan]{skill_path}[/cyan]")
655
+
656
+ for skill_name in skipped_skills:
657
+ skill_path = claude_skill_root / skill_name / "SKILL.md"
658
+ console.print(f"[yellow]⊘[/yellow] Already exists: [dim]{skill_path}[/dim]")
659
+
660
+ for skill_name, error in failed_skills:
661
+ console.print(f"[red]✗[/red] Failed to install {skill_name}: {error}")
662
+
663
+ if skipped_skills and not installed_skills:
664
+ console.print("[dim]Use --force to overwrite existing skills[/dim]")
665
+ elif installed_skills:
666
+ skill_names = ", ".join(f"/{s}" for s in installed_skills)
667
+ console.print(f"[dim]Restart Claude Code to activate: {skill_names}[/dim]")
668
+
669
+ return 1 if failed_skills else 0
670
+
realign/commands/init.py CHANGED
@@ -425,6 +425,48 @@ def _initialize_skills() -> Path:
425
425
  return skill_root
426
426
 
427
427
 
428
+ def _initialize_claude_hooks() -> Tuple[bool, list]:
429
+ """Initialize Claude Code hooks (Stop, UserPromptSubmit, PermissionRequest).
430
+
431
+ Installs all Aline hooks to the global Claude Code settings.
432
+ Does not overwrite existing hooks.
433
+
434
+ Returns:
435
+ (all_success, list of installed hook names)
436
+ """
437
+ installed_hooks = []
438
+ all_success = True
439
+
440
+ try:
441
+ from ..claude_hooks.stop_hook_installer import ensure_stop_hook_installed
442
+ if ensure_stop_hook_installed(quiet=True):
443
+ installed_hooks.append("Stop")
444
+ else:
445
+ all_success = False
446
+ except Exception:
447
+ all_success = False
448
+
449
+ try:
450
+ from ..claude_hooks.user_prompt_submit_hook_installer import ensure_user_prompt_submit_hook_installed
451
+ if ensure_user_prompt_submit_hook_installed(quiet=True):
452
+ installed_hooks.append("UserPromptSubmit")
453
+ else:
454
+ all_success = False
455
+ except Exception:
456
+ all_success = False
457
+
458
+ try:
459
+ from ..claude_hooks.permission_request_hook_installer import ensure_permission_request_hook_installed
460
+ if ensure_permission_request_hook_installed(quiet=True):
461
+ installed_hooks.append("PermissionRequest")
462
+ else:
463
+ all_success = False
464
+ except Exception:
465
+ all_success = False
466
+
467
+ return all_success, installed_hooks
468
+
469
+
428
470
  def init_global(
429
471
  force: bool = False,
430
472
  ) -> Dict[str, Any]:
@@ -444,6 +486,7 @@ def init_global(
444
486
  "prompts_dir": None,
445
487
  "tmux_conf": None,
446
488
  "skills_path": None,
489
+ "hooks_installed": None,
447
490
  "message": "",
448
491
  "errors": [],
449
492
  }
@@ -512,8 +555,14 @@ def init_global(
512
555
  skills_path = _initialize_skills()
513
556
  result["skills_path"] = str(skills_path)
514
557
 
558
+ # Initialize Claude Code hooks (Stop, UserPromptSubmit, PermissionRequest)
559
+ hooks_success, hooks_installed = _initialize_claude_hooks()
560
+ result["hooks_installed"] = hooks_installed
561
+ if not hooks_success:
562
+ result["errors"].append("Some Claude Code hooks failed to install")
563
+
515
564
  result["success"] = True
516
- result["message"] = "Aline initialized successfully (global config + database + prompts + tmux + skills ready)"
565
+ result["message"] = "Aline initialized successfully (global config + database + prompts + tmux + skills + hooks ready)"
517
566
 
518
567
  except Exception as e:
519
568
  result["errors"].append(f"Initialization failed: {e}")
@@ -589,6 +638,12 @@ def init_command(
589
638
  console.print(f" Tmux: [cyan]{result.get('tmux_conf', 'N/A')}[/cyan]")
590
639
  console.print(f" Skills: [cyan]{result.get('skills_path', 'N/A')}[/cyan]")
591
640
 
641
+ hooks_installed = result.get("hooks_installed") or []
642
+ if hooks_installed:
643
+ console.print(f" Hooks: [cyan]{', '.join(hooks_installed)}[/cyan]")
644
+ else:
645
+ console.print(" Hooks: [yellow]None installed[/yellow]")
646
+
592
647
  if result.get("success") and should_start:
593
648
  console.print("\n[bold]Watcher:[/bold]")
594
649
  if watcher_started:
@@ -14,6 +14,7 @@ import shutil
14
14
  import stat
15
15
  import subprocess
16
16
  import sys
17
+ import time
17
18
  import uuid
18
19
  from dataclasses import dataclass
19
20
  from pathlib import Path
@@ -38,6 +39,8 @@ OPT_SESSION_TYPE = "@aline_session_type"
38
39
  OPT_SESSION_ID = "@aline_session_id"
39
40
  OPT_TRANSCRIPT_PATH = "@aline_transcript_path"
40
41
  OPT_CONTEXT_ID = "@aline_context_id"
42
+ OPT_ATTENTION = "@aline_attention"
43
+ OPT_CREATED_AT = "@aline_created_at"
41
44
 
42
45
 
43
46
  @dataclass(frozen=True)
@@ -49,7 +52,10 @@ class InnerWindow:
49
52
  provider: str | None = None
50
53
  session_type: str | None = None
51
54
  session_id: str | None = None
55
+ transcript_path: str | None = None
52
56
  context_id: str | None = None
57
+ attention: str | None = None # "permission_request", "stop", or None
58
+ created_at: float | None = None # Unix timestamp when window was created
53
59
 
54
60
 
55
61
  def tmux_available() -> bool:
@@ -144,8 +150,35 @@ def new_context_id(prefix: str = "cc") -> str:
144
150
  def shell_command_with_env(command: str, env: dict[str, str]) -> str:
145
151
  if not env:
146
152
  return command
147
- prefix = " ".join(f"{k}={shlex.quote(v)}" for k, v in env.items())
148
- return f"{prefix} {command}"
153
+ # Important: callers often pass compound shell commands like `cd ... && zsh -lc ...`.
154
+ # `VAR=... cd ... && ...` only applies VAR to the first command (`cd`) in POSIX sh.
155
+ # Wrap in a subshell so env vars apply to the entire script.
156
+ assignments = " ".join(f"{k}={shlex.quote(v)}" for k, v in env.items())
157
+ return f"env {assignments} sh -lc {shlex.quote(command)}"
158
+
159
+
160
+ _SESSION_ID_FROM_TRANSCRIPT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{7,}$")
161
+
162
+
163
+ def _session_id_from_transcript_path(transcript_path: str | None) -> str | None:
164
+ raw = (transcript_path or "").strip()
165
+ if not raw:
166
+ return None
167
+ try:
168
+ path = Path(raw)
169
+ except Exception:
170
+ return None
171
+ if path.suffix != ".jsonl":
172
+ return None
173
+ stem = (path.stem or "").strip()
174
+ if not stem:
175
+ return None
176
+ # Heuristic guard: avoid overwriting with generic filenames like "transcript.jsonl".
177
+ if not _SESSION_ID_FROM_TRANSCRIPT_RE.fullmatch(stem):
178
+ return None
179
+ if not any(ch.isdigit() for ch in stem):
180
+ return None
181
+ return stem
149
182
 
150
183
 
151
184
  def _load_terminal_state() -> dict[str, dict[str, str]]:
@@ -449,7 +482,13 @@ def list_inner_windows() -> list[InnerWindow]:
449
482
  + "}\t#{"
450
483
  + OPT_SESSION_ID
451
484
  + "}\t#{"
485
+ + OPT_TRANSCRIPT_PATH
486
+ + "}\t#{"
452
487
  + OPT_CONTEXT_ID
488
+ + "}\t#{"
489
+ + OPT_ATTENTION
490
+ + "}\t#{"
491
+ + OPT_CREATED_AT
453
492
  + "}",
454
493
  ],
455
494
  capture=True,
@@ -468,7 +507,16 @@ def list_inner_windows() -> list[InnerWindow]:
468
507
  provider = parts[4] if len(parts) > 4 and parts[4] else None
469
508
  session_type = parts[5] if len(parts) > 5 and parts[5] else None
470
509
  session_id = parts[6] if len(parts) > 6 and parts[6] else None
471
- context_id = parts[7] if len(parts) > 7 and parts[7] else None
510
+ transcript_path = parts[7] if len(parts) > 7 and parts[7] else None
511
+ context_id = parts[8] if len(parts) > 8 and parts[8] else None
512
+ attention = parts[9] if len(parts) > 9 and parts[9] else None
513
+ created_at_str = parts[10] if len(parts) > 10 and parts[10] else None
514
+ created_at: float | None = None
515
+ if created_at_str:
516
+ try:
517
+ created_at = float(created_at_str)
518
+ except ValueError:
519
+ pass
472
520
 
473
521
  if terminal_id:
474
522
  persisted = state.get(terminal_id) or {}
@@ -478,9 +526,15 @@ def list_inner_windows() -> list[InnerWindow]:
478
526
  session_type = persisted.get("session_type") or session_type
479
527
  if not session_id:
480
528
  session_id = persisted.get("session_id") or session_id
529
+ if not transcript_path:
530
+ transcript_path = persisted.get("transcript_path") or transcript_path
481
531
  if not context_id:
482
532
  context_id = persisted.get("context_id") or context_id
483
533
 
534
+ transcript_session_id = _session_id_from_transcript_path(transcript_path)
535
+ if transcript_session_id:
536
+ session_id = transcript_session_id
537
+
484
538
  windows.append(
485
539
  InnerWindow(
486
540
  window_id=window_id,
@@ -490,9 +544,14 @@ def list_inner_windows() -> list[InnerWindow]:
490
544
  provider=provider,
491
545
  session_type=session_type,
492
546
  session_id=session_id,
547
+ transcript_path=transcript_path,
493
548
  context_id=context_id,
549
+ attention=attention,
550
+ created_at=created_at,
494
551
  )
495
552
  )
553
+ # Sort by creation time (newest first). Windows without created_at go to the bottom.
554
+ windows.sort(key=lambda w: w.created_at if w.created_at is not None else 0, reverse=True)
496
555
  return windows
497
556
 
498
557
 
@@ -527,6 +586,9 @@ def create_inner_window(
527
586
  existing = list_inner_windows()
528
587
  name = _unique_name((w.window_name for w in existing), base_name)
529
588
 
589
+ # Record creation time before creating the window
590
+ created_at = time.time()
591
+
530
592
  proc = _run_inner_tmux(
531
593
  [
532
594
  "new-window",
@@ -549,18 +611,18 @@ def create_inner_window(
549
611
  return None
550
612
  window_id, window_name = (created[0].split("\t", 1) + [""])[:2]
551
613
 
552
- if terminal_id or provider or context_id:
553
- opts: dict[str, str] = {}
554
- if terminal_id:
555
- opts[OPT_TERMINAL_ID] = terminal_id
556
- if provider:
557
- opts[OPT_PROVIDER] = provider
558
- if context_id:
559
- opts[OPT_CONTEXT_ID] = context_id
560
- opts.setdefault(OPT_SESSION_TYPE, "")
561
- opts.setdefault(OPT_SESSION_ID, "")
562
- opts.setdefault(OPT_TRANSCRIPT_PATH, "")
563
- set_inner_window_options(window_id, opts)
614
+ # Always set options including the creation timestamp
615
+ opts: dict[str, str] = {OPT_CREATED_AT: str(created_at)}
616
+ if terminal_id:
617
+ opts[OPT_TERMINAL_ID] = terminal_id
618
+ if provider:
619
+ opts[OPT_PROVIDER] = provider
620
+ if context_id:
621
+ opts[OPT_CONTEXT_ID] = context_id
622
+ opts.setdefault(OPT_SESSION_TYPE, "")
623
+ opts.setdefault(OPT_SESSION_ID, "")
624
+ opts.setdefault(OPT_TRANSCRIPT_PATH, "")
625
+ set_inner_window_options(window_id, opts)
564
626
 
565
627
  _run_inner_tmux(["select-window", "-t", window_id])
566
628
 
@@ -571,6 +633,7 @@ def create_inner_window(
571
633
  terminal_id=terminal_id,
572
634
  provider=provider,
573
635
  context_id=context_id,
636
+ created_at=created_at,
574
637
  )
575
638
 
576
639
 
@@ -580,6 +643,16 @@ def select_inner_window(window_id: str) -> bool:
580
643
  return _run_inner_tmux(["select-window", "-t", window_id]).returncode == 0
581
644
 
582
645
 
646
+ def clear_attention(window_id: str) -> bool:
647
+ """Clear the attention state for a window (e.g., after user acknowledges permission request)."""
648
+ if not ensure_inner_session():
649
+ return False
650
+ return (
651
+ _run_inner_tmux(["set-option", "-w", "-t", window_id, OPT_ATTENTION, ""]).returncode
652
+ == 0
653
+ )
654
+
655
+
583
656
  def get_active_claude_context_id() -> str | None:
584
657
  """Return the active inner tmux window's Claude ALINE_CONTEXT_ID (if any)."""
585
658
  try: