aline-ai 0.5.3__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aline-ai
3
- Version: 0.5.3
3
+ Version: 0.5.4
4
4
  Summary: Shared AI memory; everyone knows everything in teams
5
5
  Author: Sharemind
6
6
  License: MIT
@@ -1,7 +1,7 @@
1
- aline_ai-0.5.3.dist-info/licenses/LICENSE,sha256=H8wTqV5IF1oHw_HbBtS1PSDU8G_q81yblEIL_JfV8Vo,1077
2
- realign/__init__.py,sha256=ii28HcuehiV7QZMbEa-1t_hZkRPYlMzW801qcAZLYzo,1623
1
+ aline_ai-0.5.4.dist-info/licenses/LICENSE,sha256=H8wTqV5IF1oHw_HbBtS1PSDU8G_q81yblEIL_JfV8Vo,1077
2
+ realign/__init__.py,sha256=IahJlCDpOv2BJxOCpmx8UfhzQxweVN7I9N7YQyT7zMo,1623
3
3
  realign/claude_detector.py,sha256=hU5OcFO7JH9BCVbmajAmz4TIP-EQuvz9VlpsRYuSoVM,2792
4
- realign/cli.py,sha256=CGuAGp4s7LCV7DOuF54eF2s6xe30oie7zkCF8xttXaU,29207
4
+ realign/cli.py,sha256=81zMPACOurb9YzDKdhHyNmnye5lHOv3dbNSuXmKqJ7Y,29783
5
5
  realign/codex_detector.py,sha256=vDjRWHycjzX9Pavv8IwrznMf5oyFKBn4FvqYYy3I0s4,4141
6
6
  realign/config.py,sha256=fDwXstNF80yNSUOtNJYAqkDEWZOQkzNC7cN0-_2W0KU,12223
7
7
  realign/context.py,sha256=ttQL4_Q9FNv6JA85aRslBuu97LeJPoKAgRbShaj_UiU,10006
@@ -24,7 +24,7 @@ realign/adapters/codex.py,sha256=o9XyZEPDVqskHgXSkCDovu3yJM8x6HTIIobU90MGE3s,207
24
24
  realign/adapters/gemini.py,sha256=Oaucgz6l_6Cb0GDdt0ant_Pun6B1CfrDMN9B4irIHXc,2636
25
25
  realign/adapters/registry.py,sha256=gJf9MSfd0clt653eBfcM17snrSDXeQDwVXal0N2NrHo,3303
26
26
  realign/claude_hooks/__init__.py,sha256=-2CiH5UIjPQzok2pBQ8yIVXB5oFxkzLhFXQ8NDDVy0c,594
27
- realign/claude_hooks/permission_request_hook.py,sha256=p3rTT5zyFPUzSeGW2_5uW5P7cwyK109UoFRwAd47X88,5220
27
+ realign/claude_hooks/permission_request_hook.py,sha256=jMN7UtL6bMqHObUCP5A5ysvFrooDEcd9KxtmF2-3nCw,6448
28
28
  realign/claude_hooks/permission_request_hook_installer.py,sha256=B05ey_7OT3tlJBGzBlmy6DFJdW3OGMZCCrk1HSXl0Cs,7800
29
29
  realign/claude_hooks/stop_hook.py,sha256=bcWr9jCDZErijSzVSP-kdfmoZ6DHYc6hkRaJ_A2PmQ8,11989
30
30
  realign/claude_hooks/stop_hook_installer.py,sha256=BjzabUrAPLCA0m_8ZQWqDW4eCujdPTzlwUNdVGxtRk4,7238
@@ -32,7 +32,7 @@ realign/claude_hooks/terminal_state.py,sha256=ZvdQ-ZmqEltdMoNk3lXVsbpvbAQEmf2hxT
32
32
  realign/claude_hooks/user_prompt_submit_hook.py,sha256=WD-UavhBTueN2TPfnZrnPC7DFYGEeptjUEF21EJn7Qo,10312
33
33
  realign/claude_hooks/user_prompt_submit_hook_installer.py,sha256=2xLF8yZcE7Iwib9gU-xCkA1NWxNH9Nc5CFKPYK7rtXw,5371
34
34
  realign/commands/__init__.py,sha256=sx_ck55oxaoiF4N3LugG0ZXwonUDxeEZ5uHbBKCC7K8,89
35
- realign/commands/add.py,sha256=QWNx78ASIvcaVrj1bv4524P25PdcJ-N2NAX1Q4v5wJw,12535
35
+ realign/commands/add.py,sha256=nkkHETnNprCMKoJuqirddVzJjAiLew7oatLSt3kJOV4,23620
36
36
  realign/commands/config.py,sha256=rVwWUgLQDoRh25bjNzsN2eC77aiaPB5D77UtxgS3RlY,6798
37
37
  realign/commands/context.py,sha256=tD5jQG4kXPfV57PrS1Du3JUzvY6RCbNbxsOQWdomrPg,7057
38
38
  realign/commands/export_shares.py,sha256=6omUtPK8OJsWfrzeQAx5LbM8-lOiONZtWa9btpVumVQ,136179
@@ -45,7 +45,7 @@ realign/commands/watcher.py,sha256=6EBzIc439ClqS4UG8iGd_tfb5MQMPeieAgjCc-M-NEI,1
45
45
  realign/commands/worker.py,sha256=LErEjB9T9_XatuLTS9Wn0BbSFc3ah0O9lFTzQOin4qU,22673
46
46
  realign/dashboard/__init__.py,sha256=QZkHTsGityH8UkF8rmvA3xW7dMXNe0swEWr443qfgCM,128
47
47
  realign/dashboard/app.py,sha256=SOlQ6QHt01-Jj5Daa5xyigrNhR-gXC3jupH24lcbeLY,11935
48
- realign/dashboard/tmux_manager.py,sha256=XDfznyF9-j5fKdxzSNtGWh8L0i7_AG3S_MZhgYLtsZ8,21066
48
+ realign/dashboard/tmux_manager.py,sha256=zptH81f62tita2h0Yj3HUMcbBQoDNxHDlID4s6KQXAk,22022
49
49
  realign/dashboard/screens/__init__.py,sha256=x42K31sqL5KVMtufOnZjG8LnFN7hQyN5-z8CySqbwlM,304
50
50
  realign/dashboard/screens/create_event.py,sha256=nPMZMOekduxLXBTjMmLJEQMhb33RXWRIY0uHbD5fmmM,5484
51
51
  realign/dashboard/screens/event_detail.py,sha256=WJFO7pryO0DZIMtyA-IOriFYtSLZ5Ri5AVSzjYiW1BQ,20842
@@ -59,7 +59,7 @@ realign/dashboard/widgets/header.py,sha256=ESejMT53T3sbtRrlCGJk8smUv0ts8binkshzC
59
59
  realign/dashboard/widgets/openable_table.py,sha256=GeJPDEYp0kRHShqvmPMzAePpYXRZHUNqcWNnxqsqxjA,1963
60
60
  realign/dashboard/widgets/search_panel.py,sha256=D0NXVIdNXpcnnVETfA44Muue4CyZq5XkMj4vfpNb3LQ,8635
61
61
  realign/dashboard/widgets/sessions_table.py,sha256=CfSy93AbAqGGDFxEQW4u59twZDoVnHyuMmPcSOZTiW4,21852
62
- realign/dashboard/widgets/terminal_panel.py,sha256=85FTGJVd8sRW_AoWJXFUTaVWIaB_kqtREpwkv7_GcYc,24560
62
+ realign/dashboard/widgets/terminal_panel.py,sha256=obVB9ONnR2Voun_druPiyVG-hd6gf4idXN9iOU84Jq4,29704
63
63
  realign/dashboard/widgets/watcher_panel.py,sha256=ThQfVi_GOP-KR1GDNh5WsSmPJ11TEiP_fuuyxd2sdEk,19662
64
64
  realign/dashboard/widgets/worker_panel.py,sha256=ufJYpW0nIPcek7GHa1nuMkrpNU9AY0WvtnWzhNZneW0,18547
65
65
  realign/db/__init__.py,sha256=dqFKTskcVA7qCn7JAXeUz_c5T0_g--e9BAVokmk2Ys0,1874
@@ -86,8 +86,8 @@ realign/triggers/next_turn_trigger.py,sha256=CU6jsIxA_uoV-ROIke0iwEh4-on8vuataLG
86
86
  realign/triggers/registry.py,sha256=AVlMm5xjzWLCnJMuzvfw4hMdNGlLqSTsgd3VCOZ-cHs,3799
87
87
  realign/triggers/turn_status.py,sha256=tPHUB3NnnBfMVCIYdrFt5W1840IUGEZ-3v2GEtC981g,5987
88
88
  realign/triggers/turn_summary.py,sha256=f3hEUshgv9skJ9AbfWpoYs417lsv_HK2A_vpPjgryO4,4467
89
- aline_ai-0.5.3.dist-info/METADATA,sha256=6R443dwfUWv_LioywElkXwak1fzgKbrJz_hcaeSLjo0,1597
90
- aline_ai-0.5.3.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
91
- aline_ai-0.5.3.dist-info/entry_points.txt,sha256=TvYELpMoWsUTcQdMV8tBHxCbEf_LbK4sESqK3r8PM6Y,78
92
- aline_ai-0.5.3.dist-info/top_level.txt,sha256=yIL3s2xv9nf1GwD5n71Aq_JEIV4AfzCIDNKBzewuRm4,8
93
- aline_ai-0.5.3.dist-info/RECORD,,
89
+ aline_ai-0.5.4.dist-info/METADATA,sha256=hpo9KnzjijOm4LWcK9gS851AwfbHhETS3DFarY-FONk,1597
90
+ aline_ai-0.5.4.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
91
+ aline_ai-0.5.4.dist-info/entry_points.txt,sha256=TvYELpMoWsUTcQdMV8tBHxCbEf_LbK4sESqK3r8PM6Y,78
92
+ aline_ai-0.5.4.dist-info/top_level.txt,sha256=yIL3s2xv9nf1GwD5n71Aq_JEIV4AfzCIDNKBzewuRm4,8
93
+ aline_ai-0.5.4.dist-info/RECORD,,
realign/__init__.py CHANGED
@@ -3,7 +3,7 @@
3
3
  import hashlib
4
4
  from pathlib import Path
5
5
 
6
- __version__ = "0.5.3"
6
+ __version__ = "0.5.4"
7
7
 
8
8
 
9
9
  def get_realign_dir(project_root: Path) -> Path:
@@ -25,7 +25,37 @@ stdin JSON format:
25
25
  import os
26
26
  import sys
27
27
  import json
28
+ import time
28
29
  import subprocess
30
+ from pathlib import Path
31
+
32
+
33
+ def get_signal_dir() -> Path:
34
+ """Get the signal directory for permission requests."""
35
+ signal_dir = Path.home() / ".aline" / ".signals" / "permission_request"
36
+ signal_dir.mkdir(parents=True, exist_ok=True)
37
+ return signal_dir
38
+
39
+
40
+ def write_signal_file(terminal_id: str, tool_name: str = "") -> None:
41
+ """Write a signal file to notify the dashboard of a permission request."""
42
+ try:
43
+ signal_dir = get_signal_dir()
44
+ timestamp_ms = int(time.time() * 1000)
45
+ signal_file = signal_dir / f"{terminal_id}_{timestamp_ms}.signal"
46
+ tmp_file = signal_dir / f"{terminal_id}_{timestamp_ms}.signal.tmp"
47
+
48
+ signal_data = {
49
+ "terminal_id": terminal_id,
50
+ "tool_name": tool_name,
51
+ "timestamp": time.time(),
52
+ "hook_event": "PermissionRequest",
53
+ }
54
+
55
+ tmp_file.write_text(json.dumps(signal_data, indent=2))
56
+ tmp_file.replace(signal_file)
57
+ except Exception:
58
+ pass # Best effort
29
59
 
30
60
 
31
61
  def main():
@@ -142,6 +172,11 @@ def main():
142
172
  except Exception:
143
173
  pass
144
174
 
175
+ # Write signal file to notify dashboard (triggers file watcher refresh)
176
+ if terminal_id:
177
+ tool_name = data.get("tool_name", "")
178
+ write_signal_file(terminal_id, tool_name)
179
+
145
180
  # Exit 0 - don't block the permission request
146
181
  sys.exit(0)
147
182
 
realign/cli.py CHANGED
@@ -157,6 +157,23 @@ def add_skills_cli(
157
157
  raise typer.Exit(code=exit_code)
158
158
 
159
159
 
160
+ @add_app.command(name="skills-dev")
161
+ def add_skills_dev_cli(
162
+ force: bool = typer.Option(False, "--force", "-f", help="Overwrite existing skills"),
163
+ ):
164
+ """Install developer skills from skill-dev/ directory.
165
+
166
+ Scans skill-dev/ for SKILL.md files and installs them to ~/.claude/skills/.
167
+ This is for developer use only.
168
+
169
+ Examples:
170
+ aline add skills-dev # Install dev skills
171
+ aline add skills-dev --force # Reinstall/update dev skills
172
+ """
173
+ exit_code = add.add_skills_dev_command(force=force)
174
+ raise typer.Exit(code=exit_code)
175
+
176
+
160
177
  @context_app.command(name="load")
161
178
  def context_load_cli(
162
179
  sessions: Optional[str] = typer.Option(
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
+
@@ -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
@@ -39,6 +40,7 @@ OPT_SESSION_ID = "@aline_session_id"
39
40
  OPT_TRANSCRIPT_PATH = "@aline_transcript_path"
40
41
  OPT_CONTEXT_ID = "@aline_context_id"
41
42
  OPT_ATTENTION = "@aline_attention"
43
+ OPT_CREATED_AT = "@aline_created_at"
42
44
 
43
45
 
44
46
  @dataclass(frozen=True)
@@ -53,6 +55,7 @@ class InnerWindow:
53
55
  transcript_path: str | None = None
54
56
  context_id: str | None = None
55
57
  attention: str | None = None # "permission_request", "stop", or None
58
+ created_at: float | None = None # Unix timestamp when window was created
56
59
 
57
60
 
58
61
  def tmux_available() -> bool:
@@ -484,6 +487,8 @@ def list_inner_windows() -> list[InnerWindow]:
484
487
  + OPT_CONTEXT_ID
485
488
  + "}\t#{"
486
489
  + OPT_ATTENTION
490
+ + "}\t#{"
491
+ + OPT_CREATED_AT
487
492
  + "}",
488
493
  ],
489
494
  capture=True,
@@ -505,6 +510,13 @@ def list_inner_windows() -> list[InnerWindow]:
505
510
  transcript_path = parts[7] if len(parts) > 7 and parts[7] else None
506
511
  context_id = parts[8] if len(parts) > 8 and parts[8] else None
507
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
508
520
 
509
521
  if terminal_id:
510
522
  persisted = state.get(terminal_id) or {}
@@ -535,8 +547,11 @@ def list_inner_windows() -> list[InnerWindow]:
535
547
  transcript_path=transcript_path,
536
548
  context_id=context_id,
537
549
  attention=attention,
550
+ created_at=created_at,
538
551
  )
539
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)
540
555
  return windows
541
556
 
542
557
 
@@ -551,11 +566,6 @@ def set_inner_window_options(window_id: str, options: dict[str, str]) -> bool:
551
566
  return ok
552
567
 
553
568
 
554
- def clear_attention(window_id: str) -> bool:
555
- """Clear attention state on a window."""
556
- return set_inner_window_options(window_id, {OPT_ATTENTION: ""})
557
-
558
-
559
569
  def kill_inner_window(window_id: str) -> bool:
560
570
  if not ensure_inner_session():
561
571
  return False
@@ -576,6 +586,9 @@ def create_inner_window(
576
586
  existing = list_inner_windows()
577
587
  name = _unique_name((w.window_name for w in existing), base_name)
578
588
 
589
+ # Record creation time before creating the window
590
+ created_at = time.time()
591
+
579
592
  proc = _run_inner_tmux(
580
593
  [
581
594
  "new-window",
@@ -598,18 +611,18 @@ def create_inner_window(
598
611
  return None
599
612
  window_id, window_name = (created[0].split("\t", 1) + [""])[:2]
600
613
 
601
- if terminal_id or provider or context_id:
602
- opts: dict[str, str] = {}
603
- if terminal_id:
604
- opts[OPT_TERMINAL_ID] = terminal_id
605
- if provider:
606
- opts[OPT_PROVIDER] = provider
607
- if context_id:
608
- opts[OPT_CONTEXT_ID] = context_id
609
- opts.setdefault(OPT_SESSION_TYPE, "")
610
- opts.setdefault(OPT_SESSION_ID, "")
611
- opts.setdefault(OPT_TRANSCRIPT_PATH, "")
612
- 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)
613
626
 
614
627
  _run_inner_tmux(["select-window", "-t", window_id])
615
628
 
@@ -620,6 +633,7 @@ def create_inner_window(
620
633
  terminal_id=terminal_id,
621
634
  provider=provider,
622
635
  context_id=context_id,
636
+ created_at=created_at,
623
637
  )
624
638
 
625
639
 
@@ -629,6 +643,16 @@ def select_inner_window(window_id: str) -> bool:
629
643
  return _run_inner_tmux(["select-window", "-t", window_id]).returncode == 0
630
644
 
631
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
+
632
656
  def get_active_claude_context_id() -> str | None:
633
657
  """Return the active inner tmux window's Claude ALINE_CONTEXT_ID (if any)."""
634
658
  try:
@@ -13,16 +13,127 @@ import re
13
13
  import shlex
14
14
  import subprocess
15
15
  from pathlib import Path
16
+ from typing import Callable
16
17
 
17
18
  from textual.app import ComposeResult
18
19
  from textual.containers import Container, Horizontal, Vertical, VerticalScroll
20
+ from textual.message import Message
19
21
  from textual.widgets import Button, Static
20
22
  from rich.text import Text
21
23
 
22
24
  from .. import tmux_manager
23
25
 
24
26
 
27
+ # Signal directory for permission request notifications
28
+ PERMISSION_SIGNAL_DIR = Path.home() / ".aline" / ".signals" / "permission_request"
29
+
30
+
31
+ class _SignalFileWatcher:
32
+ """Watches for new signal files in the permission_request directory.
33
+
34
+ Uses OS-native file watching via asyncio when available,
35
+ otherwise falls back to checking directory mtime.
36
+ """
37
+
38
+ def __init__(self, callback: Callable[[], None]) -> None:
39
+ self._callback = callback
40
+ self._running = False
41
+ self._task: asyncio.Task | None = None
42
+ self._last_mtime: float = 0
43
+ self._seen_files: set[str] = set()
44
+
45
+ def start(self) -> None:
46
+ if self._running:
47
+ return
48
+ self._running = True
49
+ # Initialize seen files
50
+ self._scan_existing_files()
51
+ self._task = asyncio.create_task(self._watch_loop())
52
+
53
+ def stop(self) -> None:
54
+ self._running = False
55
+ if self._task:
56
+ self._task.cancel()
57
+ self._task = None
58
+
59
+ def _scan_existing_files(self) -> None:
60
+ """Record existing signal files so we only react to new ones."""
61
+ try:
62
+ if PERMISSION_SIGNAL_DIR.exists():
63
+ self._seen_files = {
64
+ f.name for f in PERMISSION_SIGNAL_DIR.iterdir()
65
+ if f.suffix == ".signal"
66
+ }
67
+ except Exception:
68
+ self._seen_files = set()
69
+
70
+ async def _watch_loop(self) -> None:
71
+ """Watch for new signal files using directory mtime checks."""
72
+ try:
73
+ while self._running:
74
+ # Wait a bit before checking (reduces CPU usage)
75
+ await asyncio.sleep(0.5)
76
+
77
+ if not self._running:
78
+ break
79
+
80
+ try:
81
+ if not PERMISSION_SIGNAL_DIR.exists():
82
+ continue
83
+
84
+ # Check if directory was modified
85
+ current_mtime = PERMISSION_SIGNAL_DIR.stat().st_mtime
86
+ if current_mtime <= self._last_mtime:
87
+ continue
88
+ self._last_mtime = current_mtime
89
+
90
+ # Check for new signal files
91
+ current_files = {
92
+ f.name for f in PERMISSION_SIGNAL_DIR.iterdir()
93
+ if f.suffix == ".signal"
94
+ }
95
+ new_files = current_files - self._seen_files
96
+
97
+ if new_files:
98
+ self._seen_files = current_files
99
+ # New signal file detected - trigger callback
100
+ self._callback()
101
+ # Clean up old signal files (keep last 10)
102
+ self._cleanup_old_signals()
103
+
104
+ except Exception:
105
+ pass # Ignore errors, keep watching
106
+
107
+ except asyncio.CancelledError:
108
+ pass
109
+
110
+ def _cleanup_old_signals(self) -> None:
111
+ """Remove old signal files to prevent directory from growing."""
112
+ try:
113
+ if not PERMISSION_SIGNAL_DIR.exists():
114
+ return
115
+ files = sorted(
116
+ PERMISSION_SIGNAL_DIR.glob("*.signal"),
117
+ key=lambda f: f.stat().st_mtime,
118
+ reverse=True
119
+ )
120
+ # Keep only the 10 most recent
121
+ for f in files[10:]:
122
+ try:
123
+ f.unlink()
124
+ except Exception:
125
+ pass
126
+ except Exception:
127
+ pass
128
+
129
+
25
130
  class TerminalPanel(Container, can_focus=True):
131
+ """Terminal controls panel with permission request notifications."""
132
+
133
+ class PermissionRequestDetected(Message):
134
+ """Posted when a new permission request signal file is detected."""
135
+ pass
136
+
26
137
  DEFAULT_CSS = """
27
138
  TerminalPanel {
28
139
  height: 100%;
@@ -179,6 +290,7 @@ class TerminalPanel(Container, can_focus=True):
179
290
  super().__init__()
180
291
  self._refresh_lock = asyncio.Lock()
181
292
  self._expanded_window_id: str | None = None
293
+ self._signal_watcher: _SignalFileWatcher | None = None
182
294
 
183
295
  def compose(self) -> ComposeResult:
184
296
  controls_enabled = self.supported()
@@ -205,6 +317,40 @@ class TerminalPanel(Container, can_focus=True):
205
317
  exclusive=True,
206
318
  )
207
319
  )
320
+ # Start watching for permission request signals
321
+ self._start_signal_watcher()
322
+
323
+ def on_hide(self) -> None:
324
+ # Stop watching when panel is hidden
325
+ self._stop_signal_watcher()
326
+
327
+ def _start_signal_watcher(self) -> None:
328
+ """Start watching for permission request signal files."""
329
+ if self._signal_watcher is not None:
330
+ return
331
+ self._signal_watcher = _SignalFileWatcher(self._on_permission_signal)
332
+ self._signal_watcher.start()
333
+
334
+ def _stop_signal_watcher(self) -> None:
335
+ """Stop watching for permission request signal files."""
336
+ if self._signal_watcher is not None:
337
+ self._signal_watcher.stop()
338
+ self._signal_watcher = None
339
+
340
+ def _on_permission_signal(self) -> None:
341
+ """Called when a new permission request signal is detected."""
342
+ # Post message to trigger refresh on the main thread
343
+ self.post_message(self.PermissionRequestDetected())
344
+
345
+ def on_terminal_panel_permission_request_detected(
346
+ self, event: PermissionRequestDetected
347
+ ) -> None:
348
+ """Handle permission request detection - refresh the terminal list."""
349
+ self.run_worker(
350
+ self.refresh_data(),
351
+ group="terminal-panel-refresh",
352
+ exclusive=True,
353
+ )
208
354
 
209
355
  async def refresh_data(self) -> None:
210
356
  async with self._refresh_lock: