workforest 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
workforest/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Workforest — git worktree forest management."""
2
+
3
+ __version__ = "0.1.0"
workforest/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from workforest.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
workforest/cli.py ADDED
@@ -0,0 +1,254 @@
1
+ """argparse front-end: shortcut dispatch, error → exit-code mapping, and the
2
+ sole writer to stdout (DESIGN §3.3/§5)."""
3
+
4
+ import argparse
5
+ import os
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from workforest import __version__, commands, output
11
+ from workforest.commands import CommandResult
12
+ from workforest.errors import EXIT_CANCELLED, EXIT_OK, WorkforestError
13
+ from workforest.launch import ShellAction
14
+
15
+ SUBCOMMANDS = frozenset(
16
+ {
17
+ "create",
18
+ "open",
19
+ "list",
20
+ "delete",
21
+ "checkout",
22
+ "run",
23
+ "tui",
24
+ "init",
25
+ "config",
26
+ "shell-init",
27
+ }
28
+ )
29
+
30
+
31
+ def _claude_available() -> bool:
32
+ """Feature gate (DESIGN §3.7): the integration is invisible without
33
+ ~/.claude. Filesystem check only — core never imports the integration."""
34
+ return (Path.home() / ".claude").is_dir()
35
+
36
+
37
+ def _known_subcommands() -> frozenset[str]:
38
+ if _claude_available():
39
+ return SUBCOMMANDS | {"claude"}
40
+ return SUBCOMMANDS
41
+
42
+
43
+ def _emit(result: CommandResult) -> None:
44
+ match result:
45
+ case ShellAction(script=script):
46
+ print(script)
47
+ case str() as text if text:
48
+ print(text)
49
+ case _:
50
+ pass
51
+
52
+
53
+ # --- handlers -----------------------------------------------------------
54
+
55
+
56
+ def _handle_create(ns: argparse.Namespace) -> CommandResult:
57
+ ctx = commands.build_context()
58
+ return commands.cmd_create(
59
+ ctx,
60
+ ns.branch,
61
+ opener=ns.opener,
62
+ path_arg=ns.path,
63
+ no_hooks=ns.no_hooks,
64
+ no_open=ns.no_open,
65
+ )
66
+
67
+
68
+ def _handle_open(ns: argparse.Namespace) -> CommandResult:
69
+ ctx = commands.build_context()
70
+ return commands.cmd_open(ctx, ns.name, opener=ns.opener, path_arg=ns.path)
71
+
72
+
73
+ def _handle_list(ns: argparse.Namespace) -> CommandResult:
74
+ ctx = commands.build_context()
75
+ return commands.cmd_list(ctx, porcelain=ns.porcelain)
76
+
77
+
78
+ def _handle_delete(ns: argparse.Namespace) -> CommandResult:
79
+ ctx = commands.build_context()
80
+ delete_branch: bool | None = None
81
+ if ns.delete_branch:
82
+ delete_branch = True
83
+ elif ns.keep_branch:
84
+ delete_branch = False
85
+ return commands.cmd_delete(ctx, ns.names, force=ns.force, delete_branch=delete_branch)
86
+
87
+
88
+ def _handle_checkout(ns: argparse.Namespace) -> CommandResult:
89
+ ctx = commands.build_context()
90
+ return commands.cmd_checkout(ctx, ns.name, force=ns.force)
91
+
92
+
93
+ def _handle_run(ns: argparse.Namespace) -> CommandResult:
94
+ ctx = commands.build_context()
95
+ return commands.cmd_run(ctx, ns.script, ns.args)
96
+
97
+
98
+ def _handle_init(ns: argparse.Namespace) -> CommandResult:
99
+ ctx = commands.build_context()
100
+ return commands.cmd_init(ctx, local=ns.local)
101
+
102
+
103
+ def _handle_config(ns: argparse.Namespace) -> CommandResult:
104
+ return commands.cmd_config_show(as_json=ns.json)
105
+
106
+
107
+ def _handle_shell_init(ns: argparse.Namespace) -> CommandResult:
108
+ from workforest import shellinit
109
+
110
+ return shellinit.shell_init(ns.shell)
111
+
112
+
113
+ def _handle_tui(ns: argparse.Namespace) -> CommandResult:
114
+ from workforest import tui
115
+
116
+ return tui.run(ns.mode)
117
+
118
+
119
+ def _handle_claude(ns: argparse.Namespace) -> CommandResult:
120
+ from workforest.integrations import claude
121
+
122
+ claude.cmd_copy_session(ns.session_id)
123
+ return None
124
+
125
+
126
+ # --- parser -------------------------------------------------------------
127
+
128
+
129
+ def build_parser() -> argparse.ArgumentParser:
130
+ parser = argparse.ArgumentParser(
131
+ prog="workforest",
132
+ description="Git worktree forest management",
133
+ epilog=(
134
+ "shortcut: `workforest OPENER [NAME] [-p PATH]` "
135
+ "is `workforest open [NAME] -o OPENER [-p PATH]`"
136
+ ),
137
+ )
138
+ parser.add_argument("--version", action="version", version=f"workforest {__version__}")
139
+ sub = parser.add_subparsers(dest="command", required=True)
140
+
141
+ def opener_args(p: argparse.ArgumentParser) -> None:
142
+ p.add_argument("-o", "--opener", help="opener name or command template")
143
+ p.add_argument("-p", "--path", help="path inside the worktree to open")
144
+
145
+ p = sub.add_parser("create", help="create (or reuse) a worktree for a branch and open it")
146
+ p.add_argument("branch", nargs="?", help="branch name (default: current branch)")
147
+ opener_args(p)
148
+ p.add_argument("--no-hooks", action="store_true", help="skip symlinks and setup scripts")
149
+ p.add_argument("--no-open", action="store_true", help="create only, do not open")
150
+ p.set_defaults(func=_handle_create)
151
+
152
+ p = sub.add_parser("open", help="open an existing worktree")
153
+ p.add_argument("name", nargs="?", help="worktree directory name")
154
+ opener_args(p)
155
+ p.set_defaults(func=_handle_open)
156
+
157
+ p = sub.add_parser("list", help="list managed worktrees")
158
+ p.add_argument("--porcelain", action="store_true", help="stable tab-separated output")
159
+ p.set_defaults(func=_handle_list)
160
+
161
+ p = sub.add_parser("delete", help="delete worktree(s)")
162
+ p.add_argument("names", nargs="+", metavar="NAME")
163
+ p.add_argument("--force", action="store_true", help="skip the dirty-worktree confirmation")
164
+ group = p.add_mutually_exclusive_group()
165
+ group.add_argument("--delete-branch", action="store_true", help="also delete the branch")
166
+ group.add_argument("--keep-branch", action="store_true", help="never delete the branch")
167
+ p.set_defaults(func=_handle_delete)
168
+
169
+ p = sub.add_parser("checkout", help="delete a worktree and check its branch out in main")
170
+ p.add_argument("name", metavar="NAME")
171
+ p.add_argument("--force", action="store_true", help="skip the dirty-worktree confirmation")
172
+ p.set_defaults(func=_handle_checkout)
173
+
174
+ p = sub.add_parser("run", help="run a named script from the merged config")
175
+ p.add_argument("script", metavar="SCRIPT")
176
+ p.add_argument(
177
+ "args",
178
+ nargs=argparse.REMAINDER,
179
+ metavar="ARGS",
180
+ help="appended (shell-quoted) to the script command",
181
+ )
182
+ p.set_defaults(func=_handle_run)
183
+
184
+ p = sub.add_parser("init", help="scaffold a .workforest.yaml project config")
185
+ p.add_argument(
186
+ "--local",
187
+ action="store_true",
188
+ help="place it in .vscode/ or .idea/ (untracked per-developer override)",
189
+ )
190
+ p.set_defaults(func=_handle_init)
191
+
192
+ p = sub.add_parser("config", help="show the merged configuration and its sources")
193
+ p.add_argument("--json", action="store_true")
194
+ p.set_defaults(func=_handle_config)
195
+
196
+ p = sub.add_parser("shell-init", help="print the wf shell wrapper (eval in your shell rc)")
197
+ p.add_argument("shell", nargs="?", choices=("bash", "zsh"), help="default: from $SHELL")
198
+ p.set_defaults(func=_handle_shell_init)
199
+
200
+ p = sub.add_parser("tui", help="interactive mode (requires fzf)")
201
+ p.add_argument("mode", nargs="?", help="initial mode (create/open/checkout/delete)")
202
+ p.set_defaults(func=_handle_tui)
203
+
204
+ if _claude_available():
205
+ p = sub.add_parser("claude", help="Claude Code integration")
206
+ claude_sub = p.add_subparsers(dest="claude_command", required=True)
207
+ cp = claude_sub.add_parser(
208
+ "copy-session", help="copy a session from the main worktree into this one"
209
+ )
210
+ cp.add_argument("session_id", metavar="SESSION_ID")
211
+ cp.set_defaults(func=_handle_claude)
212
+
213
+ return parser
214
+
215
+
216
+ def _preprocess(argv: list[str]) -> list[str]:
217
+ """No args → TUI; unknown first word → opener shortcut for `open`."""
218
+ if not argv:
219
+ return ["tui"]
220
+ first = argv[0]
221
+ if first.startswith("-") or first in _known_subcommands():
222
+ return argv
223
+ return ["open", *argv[1:], "-o", first]
224
+
225
+
226
+ def main(argv: list[str] | None = None) -> int:
227
+ args = _preprocess(list(sys.argv[1:] if argv is None else argv))
228
+
229
+ if args and args[0] == "--complete":
230
+ from workforest import completions
231
+
232
+ topic = args[1] if len(args) > 1 else ""
233
+ for line in completions.complete(topic):
234
+ print(line)
235
+ return EXIT_OK
236
+
237
+ parser = build_parser()
238
+ try:
239
+ ns = parser.parse_args(args)
240
+ except SystemExit as exc: # argparse exits for --help/--version/usage errors
241
+ return int(exc.code) if isinstance(exc.code, int) else EXIT_OK
242
+
243
+ try:
244
+ handler: Any = ns.func
245
+ _emit(handler(ns))
246
+ return EXIT_OK
247
+ except WorkforestError as exc:
248
+ if os.environ.get("WORKFOREST_DEBUG"):
249
+ raise
250
+ output.error(str(exc))
251
+ return exc.exit_code
252
+ except KeyboardInterrupt:
253
+ output.info("")
254
+ return EXIT_CANCELLED
workforest/commands.py ADDED
@@ -0,0 +1,284 @@
1
+ """Core commands. Each returns a ShellAction (stdout directive), a str
2
+ (stdout text), or None — cli.py is the sole stdout writer (DESIGN §5)."""
3
+
4
+ import importlib.resources
5
+ import json
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ import yaml
10
+
11
+ from workforest import gitutil, hooks, launch, output
12
+ from workforest.config import (
13
+ PROJECT_LOCAL_DIRS,
14
+ Config,
15
+ load_config,
16
+ resolve_worktrees_dir,
17
+ )
18
+ from workforest.errors import CancelledError, NotARepoError, UsageError, WorkforestError
19
+ from workforest.launch import ShellAction
20
+
21
+ type CommandResult = ShellAction | str | None
22
+
23
+
24
+ @dataclass(slots=True)
25
+ class Context:
26
+ cwd_root: Path # repo root of the invocation directory
27
+ main: Path # main worktree ($WF_MAIN)
28
+ config: Config
29
+ worktrees_dir: Path
30
+
31
+ @property
32
+ def repo_name(self) -> str:
33
+ return self.main.name
34
+
35
+
36
+ def build_context(cwd: Path | None = None) -> Context:
37
+ cwd_root = gitutil.repo_root(cwd)
38
+ main = gitutil.main_worktree(cwd_root)
39
+ config = load_config(main)
40
+ worktrees_dir = resolve_worktrees_dir(config, main)
41
+ return Context(cwd_root=cwd_root, main=main, config=config, worktrees_dir=worktrees_dir)
42
+
43
+
44
+ def managed_worktrees(ctx: Context) -> list[gitutil.Worktree]:
45
+ """Worktrees located directly inside the resolved worktrees dir
46
+ (DESIGN §3.5) — the only ones we list, complete, or delete."""
47
+ return [
48
+ worktree
49
+ for worktree in gitutil.list_worktrees(ctx.main)
50
+ if not worktree.is_main and worktree.path.parent == ctx.worktrees_dir
51
+ ]
52
+
53
+
54
+ def find_managed(ctx: Context, name: str) -> gitutil.Worktree:
55
+ for worktree in managed_worktrees(ctx):
56
+ if worktree.name == name:
57
+ return worktree
58
+ raise WorkforestError(f"worktree {name!r} not found in {ctx.worktrees_dir}")
59
+
60
+
61
+ def short_branch_name(branch: str) -> str:
62
+ return branch.rsplit("/", 1)[-1]
63
+
64
+
65
+ def _script_env(ctx: Context, worktree: Path, branch: str | None) -> dict[str, str]:
66
+ return hooks.script_env(
67
+ main=ctx.main,
68
+ worktree=worktree,
69
+ worktrees_dir=ctx.worktrees_dir,
70
+ branch=branch,
71
+ )
72
+
73
+
74
+ def cmd_create(
75
+ ctx: Context,
76
+ branch: str | None,
77
+ *,
78
+ opener: str | None = None,
79
+ path_arg: str | None = None,
80
+ no_hooks: bool = False,
81
+ no_open: bool = False,
82
+ ) -> CommandResult:
83
+ if not branch:
84
+ branch = gitutil.current_branch(ctx.cwd_root)
85
+ if branch == "HEAD":
86
+ raise WorkforestError("detached HEAD: specify a branch name")
87
+
88
+ existing = gitutil.find_branch_worktree(branch, ctx.main)
89
+ if existing is not None:
90
+ output.warn(f"branch {branch!r} already checked out at {existing.path}")
91
+ worktree_path = existing.path
92
+ else:
93
+ worktree_path = ctx.worktrees_dir / short_branch_name(branch)
94
+ registered = {w.path for w in gitutil.list_worktrees(ctx.main)}
95
+ if worktree_path in registered:
96
+ # Same directory name, different branch (feat/x vs fix/x): never
97
+ # silently reuse another branch's worktree.
98
+ raise WorkforestError(
99
+ f"{worktree_path} already holds a different branch; "
100
+ f"remove it first or use a different branch name"
101
+ )
102
+ if worktree_path.exists():
103
+ raise WorkforestError(f"directory exists but is not a worktree: {worktree_path}")
104
+ ctx.worktrees_dir.mkdir(parents=True, exist_ok=True)
105
+ gitutil.worktree_add(ctx.main, worktree_path, branch)
106
+ output.success(f"created worktree for {branch!r} at {worktree_path}")
107
+ if not no_hooks:
108
+ env = _script_env(ctx, worktree_path, branch)
109
+ hooks.create_symlinks(ctx.config, main=ctx.main, worktree=worktree_path)
110
+ failures = hooks.run_setup_scripts(ctx.config, worktree=worktree_path, env=env)
111
+ if failures:
112
+ output.warn(f"{failures} setup script(s) failed")
113
+
114
+ if no_open:
115
+ return None
116
+ return launch.launch(
117
+ ctx.config,
118
+ worktree=worktree_path,
119
+ repo_name=ctx.repo_name,
120
+ opener_arg=opener,
121
+ path_arg=path_arg,
122
+ )
123
+
124
+
125
+ def cmd_open(
126
+ ctx: Context,
127
+ name: str | None,
128
+ *,
129
+ opener: str | None = None,
130
+ path_arg: str | None = None,
131
+ ) -> CommandResult:
132
+ if not name:
133
+ raise UsageError("worktree name required")
134
+ worktree = find_managed(ctx, name)
135
+ return launch.launch(
136
+ ctx.config,
137
+ worktree=worktree.path,
138
+ repo_name=ctx.repo_name,
139
+ opener_arg=opener,
140
+ path_arg=path_arg,
141
+ )
142
+
143
+
144
+ def cmd_list(ctx: Context, *, porcelain: bool = False) -> CommandResult:
145
+ worktrees = managed_worktrees(ctx)
146
+ if porcelain:
147
+ lines = [
148
+ "\t".join(
149
+ (
150
+ w.name,
151
+ w.branch or "",
152
+ str(w.path),
153
+ "1" if gitutil.status_porcelain(w.path) else "0",
154
+ )
155
+ )
156
+ for w in worktrees
157
+ ]
158
+ return "\n".join(lines)
159
+ if not worktrees:
160
+ output.info(f"no worktrees in {ctx.worktrees_dir} (create one with: wf create BRANCH)")
161
+ return None
162
+ name_width = max(len(w.name) for w in worktrees)
163
+ branch_width = max(len(w.branch or "(detached)") for w in worktrees)
164
+ rows = [
165
+ f"{w.name:<{name_width}} {w.branch or '(detached)':<{branch_width}} "
166
+ f"{'dirty' if gitutil.status_porcelain(w.path) else 'clean'} {w.path}"
167
+ for w in worktrees
168
+ ]
169
+ return "\n".join(rows)
170
+
171
+
172
+ def _confirm_dirty(worktree: gitutil.Worktree, question: str, *, force: bool) -> None:
173
+ """Shared delete/checkout guard for uncommitted changes."""
174
+ if force:
175
+ return
176
+ changes = gitutil.status_porcelain(worktree.path)
177
+ if not changes:
178
+ return
179
+ lines = changes.splitlines()
180
+ output.warn(f"worktree {worktree.name!r} has uncommitted changes:")
181
+ for line in lines[:10]:
182
+ output.info(f" {line}")
183
+ if len(lines) > 10:
184
+ output.info(" ...")
185
+ if not output.confirm(question):
186
+ raise CancelledError("cancelled") from None
187
+
188
+
189
+ def cmd_delete(
190
+ ctx: Context,
191
+ names: list[str],
192
+ *,
193
+ force: bool = False,
194
+ delete_branch: bool | None = None,
195
+ ) -> CommandResult:
196
+ result: CommandResult = None
197
+ for name in names:
198
+ worktree = find_managed(ctx, name)
199
+ _confirm_dirty(worktree, "Delete anyway?", force=force)
200
+ branch = worktree.branch
201
+ gitutil.worktree_remove(ctx.main, worktree.path, force=True)
202
+ output.success(f"deleted worktree {name!r}")
203
+ if worktree.path == ctx.cwd_root:
204
+ # The shell is standing in the directory we just removed —
205
+ # move it back to the main checkout.
206
+ result = launch.cd_action(ctx.main)
207
+ if branch is None:
208
+ continue
209
+ decision = delete_branch
210
+ if decision is None and output.interactive():
211
+ decision = output.confirm(f"Also delete branch {branch!r}?")
212
+ if decision:
213
+ try:
214
+ gitutil.delete_branch(ctx.main, branch)
215
+ output.success(f"deleted branch {branch!r}")
216
+ except WorkforestError as exc:
217
+ output.warn(str(exc))
218
+ return result
219
+
220
+
221
+ def cmd_checkout(ctx: Context, name: str, *, force: bool = False) -> CommandResult:
222
+ worktree = find_managed(ctx, name)
223
+ branch = worktree.branch
224
+ if branch is None:
225
+ raise WorkforestError(f"cannot determine branch for worktree {name!r} (detached HEAD)")
226
+ _confirm_dirty(
227
+ worktree,
228
+ "Delete worktree and checkout its branch in the main repo anyway?",
229
+ force=force,
230
+ )
231
+ gitutil.worktree_remove(ctx.main, worktree.path, force=True)
232
+ output.success(f"deleted worktree {name!r}")
233
+ gitutil.checkout(ctx.main, branch)
234
+ output.success(f"checked out {branch!r} in {ctx.main}")
235
+ return launch.cd_action(ctx.main)
236
+
237
+
238
+ def cmd_run(ctx: Context, name: str, extra_args: list[str] | None = None) -> CommandResult:
239
+ branch = gitutil.current_branch(ctx.cwd_root)
240
+ env = _script_env(ctx, ctx.cwd_root, None if branch == "HEAD" else branch)
241
+ hooks.run_named_script(ctx.config, name, cwd=ctx.cwd_root, env=env, extra_args=extra_args)
242
+ return None
243
+
244
+
245
+ def _scaffold_template() -> str:
246
+ resource = importlib.resources.files("workforest") / "examples" / ".workforest.yaml"
247
+ return resource.read_text()
248
+
249
+
250
+ def cmd_init(ctx: Context, *, local: bool = False) -> CommandResult:
251
+ if local:
252
+ for candidate in PROJECT_LOCAL_DIRS:
253
+ directory = ctx.main / candidate
254
+ if directory.is_dir():
255
+ break
256
+ else:
257
+ dirs = " or ".join(f"{d}/" for d in PROJECT_LOCAL_DIRS)
258
+ raise WorkforestError(f"--local needs an IDE settings folder ({dirs}) in {ctx.main}")
259
+ else:
260
+ directory = ctx.main
261
+ target = directory / ".workforest.yaml"
262
+ if target.exists():
263
+ raise WorkforestError(f"{target} already exists")
264
+ target.write_text(_scaffold_template())
265
+ output.success(f"scaffolded {target}")
266
+ return None
267
+
268
+
269
+ def cmd_config_show(*, as_json: bool = False) -> CommandResult:
270
+ try:
271
+ ctx = build_context()
272
+ config = ctx.config
273
+ except NotARepoError:
274
+ config = load_config(None)
275
+ sources = [(layer, str(path)) for layer, path in config.sources]
276
+ if as_json:
277
+ return json.dumps({"config": config.as_dict(), "sources": sources}, indent=2)
278
+ dump = yaml.safe_dump(config.as_dict(), sort_keys=False).rstrip("\n")
279
+ lines = [dump, "", "# sources (low -> high):"]
280
+ if sources:
281
+ lines.extend(f"# {layer}: {path}" for layer, path in sources)
282
+ else:
283
+ lines.append("# (built-in defaults only)")
284
+ return "\n".join(lines)
@@ -0,0 +1,76 @@
1
+ """`workforest --complete TOPIC` backend.
2
+
3
+ Completion must never break the shell: any error yields an empty candidate
4
+ list, and everything stays on stdout as bare lines.
5
+ """
6
+
7
+ from workforest import commands, gitutil
8
+ from workforest.config import Config, load_config
9
+ from workforest.errors import WorkforestError
10
+
11
+ TOPICS = ("commands", "branches", "worktrees", "scripts", "openers", "claude-sessions")
12
+
13
+
14
+ def complete(topic: str) -> list[str]:
15
+ try:
16
+ return _complete(topic)
17
+ except WorkforestError, OSError:
18
+ return []
19
+
20
+
21
+ def _complete(topic: str) -> list[str]:
22
+ match topic:
23
+ case "commands":
24
+ return _commands()
25
+ case "branches":
26
+ return _branches()
27
+ case "worktrees":
28
+ return _worktrees()
29
+ case "scripts":
30
+ return sorted(_config().scripts)
31
+ case "openers":
32
+ return sorted(_config().openers)
33
+ case "claude-sessions":
34
+ return _claude_sessions()
35
+ case _:
36
+ return []
37
+
38
+
39
+ def _config() -> Config:
40
+ try:
41
+ return commands.build_context().config
42
+ except WorkforestError:
43
+ return load_config(None)
44
+
45
+
46
+ def _commands() -> list[str]:
47
+ from workforest.cli import _known_subcommands
48
+
49
+ return sorted(_known_subcommands()) + sorted(_config().openers)
50
+
51
+
52
+ def _branches() -> list[str]:
53
+ """Local then remote branches, minus those already checked out."""
54
+ root = gitutil.repo_root()
55
+ taken = {w.branch for w in gitutil.list_worktrees(root) if w.branch}
56
+ candidates: dict[str, None] = {}
57
+ for branch in (*gitutil.local_branches(root), *gitutil.remote_branches(root)):
58
+ if branch not in taken:
59
+ candidates.setdefault(branch)
60
+ return list(candidates)
61
+
62
+
63
+ def _worktrees() -> list[str]:
64
+ ctx = commands.build_context()
65
+ return [w.name for w in commands.managed_worktrees(ctx)]
66
+
67
+
68
+ def _claude_sessions() -> list[str]:
69
+ from workforest.integrations import claude
70
+
71
+ if not claude.available():
72
+ return []
73
+ ctx = commands.build_context()
74
+ if ctx.cwd_root == ctx.main:
75
+ return []
76
+ return [sid for sid, _ in claude.list_new_sessions(ctx.main, ctx.cwd_root)]