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.
- {aline_ai-0.5.1.dist-info → aline_ai-0.5.4.dist-info}/METADATA +1 -1
- {aline_ai-0.5.1.dist-info → aline_ai-0.5.4.dist-info}/RECORD +15 -13
- {aline_ai-0.5.1.dist-info → aline_ai-0.5.4.dist-info}/WHEEL +1 -1
- realign/__init__.py +1 -1
- realign/claude_hooks/permission_request_hook.py +189 -0
- realign/claude_hooks/permission_request_hook_installer.py +241 -0
- realign/claude_hooks/stop_hook.py +28 -0
- realign/cli.py +17 -0
- realign/commands/add.py +303 -0
- realign/commands/init.py +56 -1
- realign/dashboard/tmux_manager.py +88 -15
- realign/dashboard/widgets/terminal_panel.py +187 -5
- {aline_ai-0.5.1.dist-info → aline_ai-0.5.4.dist-info}/entry_points.txt +0 -0
- {aline_ai-0.5.1.dist-info → aline_ai-0.5.4.dist-info}/licenses/LICENSE +0 -0
- {aline_ai-0.5.1.dist-info → aline_ai-0.5.4.dist-info}/top_level.txt +0 -0
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
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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:
|