loopflow 0.6.2__tar.gz → 0.6.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. {loopflow-0.6.2 → loopflow-0.6.3}/.gitignore +1 -0
  2. {loopflow-0.6.2 → loopflow-0.6.3}/PKG-INFO +1 -1
  3. {loopflow-0.6.2 → loopflow-0.6.3}/pyproject.toml +1 -0
  4. loopflow-0.6.3/src/loopflow/__init__.py +1 -0
  5. {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/cli/__init__.py +49 -16
  6. {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/cli/run.py +70 -19
  7. {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/context.py +77 -3
  8. {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/files.py +54 -19
  9. {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/launcher.py +17 -0
  10. {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/lfd/collector.py +13 -8
  11. {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/lfd/pipelines.py +78 -82
  12. {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/lfd/runner.py +5 -1
  13. {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/lfops.py +36 -84
  14. loopflow-0.6.3/src/loopflow/pipeline.py +630 -0
  15. {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/publish.py +92 -0
  16. loopflow-0.6.3/src/loopflow/templates/commands/expand.md +27 -0
  17. loopflow-0.6.3/src/loopflow/templates/commands/explore.md +27 -0
  18. loopflow-0.6.3/src/loopflow/templates/commands/reduce.md +27 -0
  19. loopflow-0.6.2/src/loopflow/__init__.py +0 -1
  20. loopflow-0.6.2/src/loopflow/pipeline.py +0 -143
  21. {loopflow-0.6.2 → loopflow-0.6.3}/README.md +0 -0
  22. {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/LOOPFLOW.md +0 -0
  23. {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/builtins/__init__.py +0 -0
  24. {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/builtins/commit_message.txt +0 -0
  25. {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/builtins/pr_message.txt +0 -0
  26. {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/builtins/release_notes.txt +0 -0
  27. {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/config.py +0 -0
  28. {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/design.py +0 -0
  29. {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/frontmatter.py +0 -0
  30. {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/git.py +0 -0
  31. {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/init_check.py +0 -0
  32. {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/lfd/README.md +0 -0
  33. {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/lfd/__init__.py +0 -0
  34. {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/lfd/__main__.py +0 -0
  35. {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/lfd/agent_runner.py +0 -0
  36. {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/lfd/agents.py +0 -0
  37. {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/lfd/client.py +0 -0
  38. {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/lfd/cron.py +0 -0
  39. {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/lfd/db.py +0 -0
  40. {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/lfd/launchd.py +0 -0
  41. {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/lfd/models.py +0 -0
  42. {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/lfd/naming.py +0 -0
  43. {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/lfd/process.py +0 -0
  44. {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/lfd/protocol.py +0 -0
  45. {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/lfd/server.py +0 -0
  46. {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/lfd/triggers.py +0 -0
  47. {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/lfwt.py +0 -0
  48. {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/llm_http.py +0 -0
  49. {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/logging.py +0 -0
  50. {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/prompts/CHECKPOINT_MESSAGE.md +0 -0
  51. {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/prompts/COMMIT_MESSAGE.md +0 -0
  52. {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/templates/commands/debug.md +0 -0
  53. {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/templates/commands/design.md +0 -0
  54. {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/templates/commands/implement.md +0 -0
  55. {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/templates/commands/iterate.md +0 -0
  56. {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/templates/commands/polish.md +0 -0
  57. {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/templates/commands/review.md +0 -0
  58. {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/templates/config.yaml +0 -0
  59. {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/tokens.py +0 -0
  60. {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/voices.py +0 -0
  61. {loopflow-0.6.2 → loopflow-0.6.3}/src/loopflow/worktrees.py +0 -0
@@ -7,6 +7,7 @@ __pycache__/
7
7
  xcuserdata/
8
8
  .swiftpm/xcode/
9
9
  Maestro/Maestro.xcodeproj/
10
+ Maestro/dist/
10
11
 
11
12
  # Claude Code (personal state)
12
13
  .claude/settings.local.json
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: loopflow
3
- Version: 0.6.2
3
+ Version: 0.6.3
4
4
  Summary: Run LLM coding agents from reusable prompt files
5
5
  Project-URL: Homepage, https://loopflowstudio.github.io/loopflow/
6
6
  Project-URL: Repository, https://github.com/loopflowstudio/loopflow
@@ -79,4 +79,5 @@ path = "src/loopflow/__init__.py"
79
79
  [dependency-groups]
80
80
  dev = [
81
81
  "pytest>=9.0.2",
82
+ "boto3>=1.35.0",
82
83
  ]
@@ -0,0 +1 @@
1
+ __version__ = "0.6.3"
@@ -1,17 +1,19 @@
1
1
  """Loopflow CLI: Arrange LLMs to code in harmony."""
2
2
 
3
3
  import sys
4
+ from typing import Optional
4
5
 
5
6
  import typer
6
7
 
7
8
  from loopflow.config import ConfigError, load_config
8
- from loopflow.context import find_worktree_root, gather_task
9
+ from loopflow.context import find_worktree_root, gather_task, list_all_tasks, _get_builtin_task
9
10
  from loopflow.init_check import check_init_status
11
+ from loopflow.lfd.pipelines import load_pipeline
10
12
 
11
13
  app = typer.Typer(
12
14
  name="lf",
13
15
  help="Arrange LLMs to code in harmony.",
14
- no_args_is_help=True,
16
+ no_args_is_help=False,
15
17
  )
16
18
 
17
19
  # Import and register subcommands
@@ -24,6 +26,33 @@ app.command(name="pipeline")(run_module.pipeline)
24
26
  app.command()(run_module.cp)
25
27
 
26
28
 
29
+ def _list_tasks() -> None:
30
+ """List available tasks and pipelines."""
31
+ repo_root = find_worktree_root()
32
+ config = load_config(repo_root) if repo_root else None
33
+
34
+ user_tasks, builtin_only = list_all_tasks(repo_root)
35
+ pipelines = list(config.pipelines.keys()) if config else []
36
+
37
+ # Show pipelines
38
+ if pipelines:
39
+ typer.echo("Pipelines:")
40
+ for name in sorted(pipelines):
41
+ typer.echo(f" {name}")
42
+ typer.echo()
43
+
44
+ # Show tasks
45
+ if user_tasks or builtin_only:
46
+ typer.echo("Tasks:")
47
+ for name in user_tasks:
48
+ typer.echo(f" {name}")
49
+ for name in builtin_only:
50
+ typer.echo(f" {name} (builtin)")
51
+ else:
52
+ typer.echo("No tasks found.")
53
+ typer.echo("Run: lfops init")
54
+
55
+
27
56
  def main():
28
57
  """Entry point that supports 'lf <task>' and 'lf <pipeline>' shorthand."""
29
58
  known_commands = {
@@ -36,6 +65,11 @@ def main():
36
65
  }
37
66
 
38
67
  try:
68
+ # Handle 'lf' with no arguments: list available tasks
69
+ if len(sys.argv) == 1:
70
+ _list_tasks()
71
+ raise SystemExit(0)
72
+
39
73
  if len(sys.argv) > 1:
40
74
  first_arg = sys.argv[1]
41
75
 
@@ -51,8 +85,16 @@ def main():
51
85
  repo_root = find_worktree_root()
52
86
  config = load_config(repo_root) if repo_root else None
53
87
 
54
- has_pipeline = config and name in config.pipelines
55
- has_task = repo_root and gather_task(repo_root, name) is not None
88
+ # Check for pipeline in config.yaml or .lf/pipelines/
89
+ has_config_pipeline = config and name in config.pipelines
90
+ has_file_pipeline = repo_root and load_pipeline(name, repo_root) is not None
91
+ has_pipeline = has_config_pipeline or has_file_pipeline
92
+
93
+ # gather_task now includes builtins
94
+ has_task = gather_task(repo_root, name) is not None if repo_root else False
95
+ # Also check builtin even without repo_root
96
+ if not has_task and not repo_root:
97
+ has_task = _get_builtin_task(name) is not None
56
98
 
57
99
  if has_pipeline and has_task:
58
100
  typer.echo(f"Error: '{name}' exists as both a pipeline and a task", err=True)
@@ -66,18 +108,9 @@ def main():
66
108
  elif has_task:
67
109
  sys.argv.insert(1, "run")
68
110
  else:
69
- # Check if repo is initialized
70
- status = check_init_status(repo_root) if repo_root else None
71
- if status and not status.has_commands and not status.has_lf_dir:
72
- # Uninitialized repo - suggest init
73
- typer.echo(f"No task named '{name}' found.", err=True)
74
- typer.echo("", err=True)
75
- typer.echo("This repo hasn't been set up for loopflow yet.", err=True)
76
- typer.echo("Run: lfops init", err=True)
77
- else:
78
- # Initialized but task missing - suggest creating it
79
- typer.echo(f"No task or pipeline named '{name}'", err=True)
80
- typer.echo(f"Create: .claude/commands/{name}.md", err=True)
111
+ # Task not found
112
+ typer.echo(f"No task or pipeline named '{name}'", err=True)
113
+ typer.echo(f"Run 'lf' to see available tasks.", err=True)
81
114
  raise SystemExit(1)
82
115
 
83
116
  app()
@@ -23,7 +23,8 @@ from loopflow.launcher import (
23
23
  from loopflow.logging import get_model_env, write_prompt_file
24
24
  from loopflow.lfd.client import log_session_start, log_session_end
25
25
  from loopflow.lfd.models import Session, SessionStatus
26
- from loopflow.pipeline import run_pipeline
26
+ from loopflow.lfd.pipelines import load_pipeline as load_pipeline_file, PipelineDef, PipelineStep, RaceConfig
27
+ from loopflow.pipeline import run_pipeline_def, _run_race_step
27
28
  from loopflow.tokens import analyze_components
28
29
  from loopflow.worktrees import WorktreeError, create
29
30
 
@@ -78,6 +79,7 @@ def _execute_task(
78
79
  model_variant=model_variant,
79
80
  sandbox_root=repo_root.parent,
80
81
  workdir=repo_root,
82
+ images=components.image_files,
81
83
  )
82
84
  else:
83
85
  command = build_model_command(
@@ -88,6 +90,7 @@ def _execute_task(
88
90
  model_variant=model_variant,
89
91
  sandbox_root=repo_root.parent,
90
92
  workdir=repo_root,
93
+ images=components.image_files,
91
94
  )
92
95
 
93
96
  # For interactive mode, run CLI directly to preserve terminal
@@ -188,7 +191,10 @@ def run(
188
191
  None, "--voice", help="Voice(s) to use (comma-separated, e.g., 'architect,concise')"
189
192
  ),
190
193
  parallel: str = typer.Option(
191
- None, "--parallel", help="Run in parallel with multiple models (e.g., 'claude,codex')"
194
+ None, "--parallel", help="Run in parallel with multiple models, keep worktrees (e.g., 'claude,codex')"
195
+ ),
196
+ race: str = typer.Option(
197
+ None, "--race", help="Race multiple models, auto-judge winner (e.g., 'claude,codex,gemini')"
192
198
  ),
193
199
  ):
194
200
  """Run a task with an LLM model."""
@@ -197,7 +203,29 @@ def run(
197
203
  typer.echo("Error: Not in a git repository", err=True)
198
204
  raise typer.Exit(1)
199
205
 
200
- # Handle parallel execution
206
+ # Handle race execution
207
+ if race:
208
+ models = [m.strip() for m in race.split(",")]
209
+ config = load_config(repo_root)
210
+ main_repo = find_main_repo(repo_root) or repo_root
211
+ skip_permissions = config.yolo if config else False
212
+ exclude = list(config.exclude) if config and config.exclude else None
213
+
214
+ race_config = RaceConfig(models=models)
215
+ result_code = _run_race_step(
216
+ task,
217
+ race_config,
218
+ repo_root,
219
+ main_repo,
220
+ exclude,
221
+ skip_permissions,
222
+ list(context) if context else None,
223
+ 1, # step_num
224
+ 1, # total_steps
225
+ )
226
+ raise typer.Exit(result_code)
227
+
228
+ # Handle parallel execution (creates persistent worktrees)
201
229
  if parallel:
202
230
  models = [m.strip() for m in parallel.split(",")]
203
231
  for model_name in models:
@@ -518,7 +546,7 @@ def cp(
518
546
 
519
547
 
520
548
  def pipeline(
521
- name: str = typer.Argument(help="Pipeline name from config.yaml"),
549
+ name: str = typer.Argument(help="Pipeline name from config.yaml or .lf/pipelines/"),
522
550
  context: list[str] = typer.Option(
523
551
  None, "-x", "--context", help="Context files for all tasks"
524
552
  ),
@@ -542,11 +570,16 @@ def pipeline(
542
570
  raise typer.Exit(1)
543
571
 
544
572
  config = load_config(repo_root)
545
- if not config or name not in config.pipelines:
546
- typer.echo(f"Error: Pipeline '{name}' not found in .lf/config.yaml", err=True)
573
+
574
+ # Check for pipeline in .lf/pipelines/ first, then config.yaml
575
+ pipeline_def = load_pipeline_file(name, repo_root)
576
+ config_pipeline = config.pipelines.get(name) if config else None
577
+
578
+ if not pipeline_def and not config_pipeline:
579
+ typer.echo(f"Error: Pipeline '{name}' not found in .lf/pipelines/ or .lf/config.yaml", err=True)
547
580
  raise typer.Exit(1)
548
581
 
549
- agent_model = model or config.agent_model
582
+ agent_model = model or (config.agent_model if config else "claude:opus")
550
583
  backend, model_variant = parse_model(agent_model)
551
584
 
552
585
  try:
@@ -567,24 +600,32 @@ def pipeline(
567
600
  raise typer.Exit(1)
568
601
  repo_root = worktree_path
569
602
 
570
- all_context = list(config.context) if config.context else []
603
+ all_context = list(config.context) if config and config.context else []
571
604
  if context:
572
605
  all_context.extend(context)
573
606
 
574
- exclude = list(config.exclude) if config.exclude else None
607
+ exclude = list(config.exclude) if config and config.exclude else None
575
608
 
576
609
  if copy:
577
610
  # Show tokens for first task in pipeline
578
- first_task = config.pipelines[name].tasks[0]
611
+ if pipeline_def:
612
+ first_task = pipeline_def.steps[0].task if pipeline_def.steps else None
613
+ else:
614
+ first_task = config_pipeline.tasks[0] if config_pipeline.tasks else None
615
+
616
+ if not first_task:
617
+ typer.echo("Error: Pipeline has no tasks", err=True)
618
+ raise typer.Exit(1)
619
+
579
620
  components = gather_prompt_components(
580
621
  repo_root,
581
622
  first_task,
582
623
  context=all_context or None,
583
624
  exclude=exclude,
584
625
  include_tests_for=config.include_tests_for if config else None,
585
- include_loopflow_doc=config.include_loopflow_doc,
586
- include_diff=config.diff,
587
- include_diff_files=config.diff_files,
626
+ include_loopflow_doc=config.include_loopflow_doc if config else True,
627
+ include_diff=config.diff if config else False,
628
+ include_diff_files=config.diff_files if config else True,
588
629
  )
589
630
  prompt = format_prompt(components)
590
631
  _copy_to_clipboard(prompt)
@@ -594,16 +635,26 @@ def pipeline(
594
635
  typer.echo("\nCopied to clipboard.")
595
636
  raise typer.Exit(0)
596
637
 
597
- push_enabled = config.push
598
- pr_enabled = pr if pr is not None else config.pr
638
+ push_enabled = config.push if config else False
639
+ pr_enabled = pr if pr is not None else (config.pr if config else False)
640
+ skip_permissions = config.yolo if config else False
641
+
642
+ # Convert config.yaml pipeline to PipelineDef if needed
643
+ if not pipeline_def:
644
+ # PipelineConfig.push/pr override global settings
645
+ push_enabled = config_pipeline.push if config_pipeline.push is not None else push_enabled
646
+ pr_enabled = config_pipeline.pr if config_pipeline.pr is not None else pr_enabled
647
+ pipeline_def = PipelineDef(
648
+ name=name,
649
+ steps=[PipelineStep(task=t) for t in config_pipeline.tasks],
650
+ )
599
651
 
600
- exit_code = run_pipeline(
601
- config.pipelines[name],
652
+ exit_code = run_pipeline_def(
653
+ pipeline_def,
602
654
  repo_root,
603
655
  context=all_context or None,
604
656
  exclude=exclude,
605
- include_tests_for=config.include_tests_for if config else None,
606
- skip_permissions=config.yolo,
657
+ skip_permissions=skip_permissions,
607
658
  push_enabled=push_enabled,
608
659
  pr_enabled=pr_enabled,
609
660
  backend=backend,
@@ -7,11 +7,15 @@ from pathlib import Path
7
7
  from typing import Optional
8
8
 
9
9
  from loopflow.design import gather_design_docs
10
- from loopflow.files import gather_docs, gather_files, format_files
10
+ from loopflow.files import gather_docs, gather_files, format_files, format_image_references
11
11
  from loopflow.frontmatter import TaskFile, parse_task_file
12
12
  from loopflow.voices import Voice, load_voice
13
13
 
14
14
 
15
+ # Path to bundled builtin templates
16
+ _TEMPLATES_DIR = Path(__file__).parent / "templates" / "commands"
17
+
18
+
15
19
  @dataclass
16
20
  class PromptComponents:
17
21
  """Raw components of a prompt before assembly."""
@@ -25,6 +29,7 @@ class PromptComponents:
25
29
  clipboard: str | None = None
26
30
  loopflow_doc: str | None = None # Bundled system documentation
27
31
  voices: list[Voice] | None = None
32
+ image_files: list[Path] | None = None # Images for visual context
28
33
 
29
34
 
30
35
  def find_worktree_root(start: Optional[Path] = None) -> Path | None:
@@ -65,6 +70,63 @@ def _read_clipboard() -> str | None:
65
70
  return None
66
71
 
67
72
 
73
+ def _get_builtin_task(name: str) -> Path | None:
74
+ """Return path to bundled template if it exists."""
75
+ builtin = _TEMPLATES_DIR / f"{name}.md"
76
+ return builtin if builtin.exists() else None
77
+
78
+
79
+ def list_builtin_tasks() -> list[str]:
80
+ """Return names of all builtin tasks."""
81
+ if not _TEMPLATES_DIR.exists():
82
+ return []
83
+ return sorted(p.stem for p in _TEMPLATES_DIR.glob("*.md"))
84
+
85
+
86
+ def list_user_tasks(repo_root: Path) -> list[str]:
87
+ """Return names of user-defined tasks in the repo."""
88
+ tasks = set()
89
+
90
+ # .claude/commands/*.md
91
+ claude_dir = repo_root / ".claude" / "commands"
92
+ if claude_dir.exists():
93
+ for p in claude_dir.glob("*.md"):
94
+ tasks.add(p.stem)
95
+
96
+ # .lf/*
97
+ lf_dir = repo_root / ".lf"
98
+ if lf_dir.exists():
99
+ for p in lf_dir.iterdir():
100
+ if not p.is_file():
101
+ continue
102
+ # Skip config files
103
+ if p.name in ("config.yaml", "config.yml"):
104
+ continue
105
+ # Task name is filename without extension
106
+ if p.suffix:
107
+ tasks.add(p.stem)
108
+ else:
109
+ tasks.add(p.name)
110
+
111
+ return sorted(tasks)
112
+
113
+
114
+ def list_all_tasks(repo_root: Path | None) -> tuple[list[str], list[str]]:
115
+ """Return (user_tasks, builtin_only_tasks) for discoverability.
116
+
117
+ User tasks include any that override builtins.
118
+ Builtin-only tasks are builtins not overridden by user tasks.
119
+ """
120
+ builtins = set(list_builtin_tasks())
121
+ if repo_root:
122
+ user = set(list_user_tasks(repo_root))
123
+ else:
124
+ user = set()
125
+
126
+ builtin_only = builtins - user
127
+ return sorted(user), sorted(builtin_only)
128
+
129
+
68
130
  def gather_task(repo_root: Path, name: str) -> TaskFile | None:
69
131
  """Gather and parse task file with frontmatter.
70
132
 
@@ -74,6 +136,7 @@ def gather_task(repo_root: Path, name: str) -> TaskFile | None:
74
136
  3. .lf/{name}.md
75
137
  4. .lf/{name}.* (any other extension)
76
138
  5. .lf/{name} (bare name)
139
+ 6. templates/commands/{name}.md (builtin fallback)
77
140
 
78
141
  Returns TaskFile with parsed config, or None if not found.
79
142
  """
@@ -109,6 +172,13 @@ def gather_task(repo_root: Path, name: str) -> TaskFile | None:
109
172
  content = _read_file_if_named(lf_dir, name)
110
173
  if content:
111
174
  return parse_task_file(name, content)
175
+
176
+ # Fall back to builtin templates
177
+ builtin_path = _get_builtin_task(name)
178
+ if builtin_path:
179
+ content = builtin_path.read_text()
180
+ return parse_task_file(name, content)
181
+
112
182
  return None
113
183
 
114
184
 
@@ -250,7 +320,7 @@ def gather_prompt_components(
250
320
  # Merge: diff files first, then context paths not already in diff
251
321
  diff_set = set(diff_file_paths)
252
322
  all_file_paths = diff_file_paths + [p for p in context_paths if p not in diff_set]
253
- all_files = gather_files(all_file_paths, repo_root, context_exclude)
323
+ gather_result = gather_files(all_file_paths, repo_root, context_exclude)
254
324
 
255
325
  clipboard = _read_clipboard() if paste else None
256
326
 
@@ -261,12 +331,13 @@ def gather_prompt_components(
261
331
  run_mode=run_mode,
262
332
  docs=docs,
263
333
  diff=diff,
264
- diff_files=all_files,
334
+ diff_files=gather_result.text_files,
265
335
  task=task_result,
266
336
  repo_root=repo_root,
267
337
  clipboard=clipboard,
268
338
  loopflow_doc=loopflow_doc,
269
339
  voices=loaded_voices,
340
+ image_files=gather_result.image_files or None,
270
341
  )
271
342
 
272
343
 
@@ -322,6 +393,9 @@ def format_prompt(components: PromptComponents) -> str:
322
393
  if components.clipboard:
323
394
  parts.append(f"Content from clipboard.\n\n<lf:clipboard>\n{components.clipboard}\n</lf:clipboard>")
324
395
 
396
+ if components.image_files:
397
+ parts.append(format_image_references(components.image_files, components.repo_root))
398
+
325
399
  return "\n\n".join(parts)
326
400
 
327
401
 
@@ -1,5 +1,6 @@
1
1
  """File library gathering for LLM context."""
2
2
 
3
+ from dataclasses import dataclass, field
3
4
  from functools import lru_cache
4
5
  from pathlib import Path
5
6
  from typing import Optional
@@ -7,10 +8,20 @@ from typing import Optional
7
8
  import pathspec
8
9
 
9
10
 
11
+ @dataclass
12
+ class GatherResult:
13
+ """Result of gathering files for context."""
14
+ text_files: list[tuple[Path, str]] = field(default_factory=list)
15
+ image_files: list[Path] = field(default_factory=list)
16
+
17
+
18
+ # Image extensions (tracked separately, not embedded in text)
19
+ _IMAGE_EXTENSIONS: set[str] = {
20
+ ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".ico",
21
+ }
22
+
10
23
  # Known binary extensions (skip without reading)
11
- _BINARY_EXTENSIONS: set[str] = {
12
- # Images
13
- ".png", ".jpg", ".jpeg", ".gif", ".ico", ".webp", ".bmp", ".tiff",
24
+ _BINARY_EXTENSIONS: set[str] = _IMAGE_EXTENSIONS | {
14
25
  # Archives
15
26
  ".zip", ".tar", ".gz", ".bz2", ".7z", ".rar",
16
27
  # Executables/libraries
@@ -26,6 +37,11 @@ _BINARY_EXTENSIONS: set[str] = {
26
37
  }
27
38
 
28
39
 
40
+ def is_image(path: Path) -> bool:
41
+ """Check if file is an image by extension."""
42
+ return path.suffix.lower() in _IMAGE_EXTENSIONS
43
+
44
+
29
45
  def is_binary(path: Path) -> bool:
30
46
  """Check if file is binary by extension or content sniffing."""
31
47
  # Fast path: check extension
@@ -166,15 +182,6 @@ def _gather_file(
166
182
  return (path, path.read_text())
167
183
 
168
184
 
169
- def gather_file(
170
- path: Path,
171
- repo_root: Path,
172
- exclude: Optional[list[str]] = None,
173
- ) -> tuple[Path, str] | None:
174
- excluded_paths = _compile_exclude_patterns(exclude, repo_root) if exclude else None
175
- return _gather_file(path, repo_root, excluded_paths)
176
-
177
-
178
185
  def _expand_path(path_str: str, repo_root: Path) -> list[Path]:
179
186
  """Expand a path string to a list of file paths.
180
187
 
@@ -198,13 +205,15 @@ def _expand_path(path_str: str, repo_root: Path) -> list[Path]:
198
205
  return []
199
206
 
200
207
 
201
- def gather_files(paths: list[str], repo_root: Path, exclude: Optional[list[str]] = None) -> list[tuple[Path, str]]:
208
+ def gather_files(paths: list[str], repo_root: Path, exclude: Optional[list[str]] = None) -> GatherResult:
202
209
  """Gather files and their parent READMEs.
203
210
 
204
- Returns list of (path, content) tuples, deduplicated and ordered.
211
+ Returns GatherResult with text files (path, content) and image file paths.
212
+ Images are tracked separately since they can't be embedded in text prompts.
205
213
  """
206
214
  seen: set[Path] = set()
207
- results: list[tuple[Path, str]] = []
215
+ text_files: list[tuple[Path, str]] = []
216
+ image_files: list[Path] = []
208
217
 
209
218
  # Pre-compile exclude patterns once for the entire gather operation
210
219
  excluded_paths = _compile_exclude_patterns(exclude, repo_root) if exclude else None
@@ -213,19 +222,28 @@ def gather_files(paths: list[str], repo_root: Path, exclude: Optional[list[str]]
213
222
  expanded = _expand_path(path_str, repo_root)
214
223
 
215
224
  for path in expanded:
225
+ if path in seen:
226
+ continue
227
+
228
+ # Check if this is an image
229
+ if path.is_file() and is_image(path) and not _is_ignored(path, repo_root, excluded_paths):
230
+ seen.add(path)
231
+ image_files.append(path)
232
+ continue
233
+
216
234
  # Gather parent documentation first
217
235
  for doc_path, content in _gather_docs(path, repo_root, excluded_paths):
218
236
  if doc_path not in seen:
219
237
  seen.add(doc_path)
220
- results.append((doc_path, content))
238
+ text_files.append((doc_path, content))
221
239
 
222
- # Gather the file itself
240
+ # Gather the file itself (skips binary including images)
223
241
  file_result = _gather_file(path, repo_root, excluded_paths)
224
242
  if file_result and file_result[0] not in seen:
225
243
  seen.add(file_result[0])
226
- results.append(file_result)
244
+ text_files.append(file_result)
227
245
 
228
- return results
246
+ return GatherResult(text_files=text_files, image_files=image_files)
229
247
 
230
248
 
231
249
  def format_files(files: list[tuple[Path, str]], repo_root: Path) -> str:
@@ -240,3 +258,20 @@ def format_files(files: list[tuple[Path, str]], repo_root: Path) -> str:
240
258
 
241
259
  body = "\n\n".join(parts)
242
260
  return f"Reference files for this task. Includes parent documentation for context.\n\n<lf:files>\n{body}\n</lf:files>"
261
+
262
+
263
+ def format_image_references(images: list[Path], repo_root: Path) -> str:
264
+ """Format image file references for the prompt.
265
+
266
+ Images can't be embedded in text, but we tell the agent where they are
267
+ so it can read them using its file tools.
268
+ """
269
+ if not images:
270
+ return ""
271
+
272
+ lines = ["The following images are available. Use your Read tool to view them:"]
273
+ for img in images:
274
+ rel = img.relative_to(repo_root)
275
+ lines.append(f"- {rel}")
276
+
277
+ return "<lf:images>\n" + "\n".join(lines) + "\n</lf:images>"
@@ -230,6 +230,7 @@ def build_codex_command(
230
230
  model_variant: str | None = None,
231
231
  sandbox_root: Path | None = None,
232
232
  workdir: Path | None = None,
233
+ images: list[Path] | None = None,
233
234
  ) -> list[str]:
234
235
  """Build Codex CLI command for the requested run mode.
235
236
 
@@ -250,6 +251,11 @@ def build_codex_command(
250
251
  if sandbox_root:
251
252
  cmd.extend(["--add-dir", str(sandbox_root)])
252
253
 
254
+ # Attach images via -i flag
255
+ if images:
256
+ for img in images:
257
+ cmd.extend(["-i", str(img)])
258
+
253
259
  if skip_permissions:
254
260
  # Keep sandboxing but avoid approval prompts in exec mode.
255
261
  cmd.extend(["-c", 'approval_policy="never"'])
@@ -282,6 +288,7 @@ def build_codex_interactive_command(
282
288
  model_variant: str | None = None,
283
289
  sandbox_root: Path | None = None,
284
290
  workdir: Path | None = None,
291
+ images: list[Path] | None = None,
285
292
  ) -> list[str]:
286
293
  """Build Codex CLI command for interactive mode.
287
294
 
@@ -297,6 +304,10 @@ def build_codex_interactive_command(
297
304
  cmd.extend(["--sandbox", "workspace-write"])
298
305
  if sandbox_root:
299
306
  cmd.extend(["--add-dir", str(sandbox_root)])
307
+ # Attach images via -i flag
308
+ if images:
309
+ for img in images:
310
+ cmd.extend(["-i", str(img)])
300
311
  return cmd
301
312
 
302
313
 
@@ -368,10 +379,12 @@ def build_model_command(
368
379
  model_variant: str | None = None,
369
380
  sandbox_root: Path | None = None,
370
381
  workdir: Path | None = None,
382
+ images: list[Path] | None = None,
371
383
  ) -> list[str]:
372
384
  """Build a model command for auto/background execution.
373
385
 
374
386
  Prompt should be appended as a CLI argument.
387
+ Images are passed to Codex via -i flag; Claude/Gemini read from filesystem.
375
388
  """
376
389
  if model == "claude":
377
390
  return build_claude_command(auto=auto, stream=stream, skip_permissions=skip_permissions, model_variant=model_variant)
@@ -391,6 +404,7 @@ def build_model_command(
391
404
  model_variant=model_variant,
392
405
  sandbox_root=sandbox_root,
393
406
  workdir=workdir,
407
+ images=images,
394
408
  )
395
409
 
396
410
 
@@ -400,10 +414,12 @@ def build_model_interactive_command(
400
414
  model_variant: str | None = None,
401
415
  sandbox_root: Path | None = None,
402
416
  workdir: Path | None = None,
417
+ images: list[Path] | None = None,
403
418
  ) -> list[str]:
404
419
  """Build a model command for interactive execution.
405
420
 
406
421
  Prompt should be appended as a CLI argument.
422
+ Images are passed to Codex via -i flag; Claude/Gemini read from filesystem.
407
423
  """
408
424
  if model == "claude":
409
425
  return build_claude_command(auto=False, stream=False, skip_permissions=skip_permissions, model_variant=model_variant)
@@ -419,6 +435,7 @@ def build_model_interactive_command(
419
435
  model_variant=model_variant,
420
436
  sandbox_root=sandbox_root,
421
437
  workdir=workdir,
438
+ images=images,
422
439
  )
423
440
 
424
441