patchfeld 0.2.1__tar.gz → 0.2.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 (85) hide show
  1. {patchfeld-0.2.1 → patchfeld-0.2.3}/PKG-INFO +5 -3
  2. {patchfeld-0.2.1 → patchfeld-0.2.3}/README.md +4 -2
  3. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/agents/session.py +31 -1
  4. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/app.py +29 -1
  5. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/orchestrator/session.py +87 -4
  6. patchfeld-0.2.3/patchfeld/orchestrator/skills.py +193 -0
  7. patchfeld-0.2.3/patchfeld/orchestrator/slash_completion.py +247 -0
  8. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/widgets/agent_transcript.py +35 -0
  9. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/widgets/chrome.py +84 -1
  10. patchfeld-0.2.3/patchfeld/widgets/orchestrator_chat.py +140 -0
  11. {patchfeld-0.2.1 → patchfeld-0.2.3}/pyproject.toml +1 -1
  12. patchfeld-0.2.3/website/README.md +126 -0
  13. patchfeld-0.2.1/patchfeld/widgets/orchestrator_chat.py +0 -73
  14. {patchfeld-0.2.1 → patchfeld-0.2.3}/.gitignore +0 -0
  15. {patchfeld-0.2.1 → patchfeld-0.2.3}/LICENSE +0 -0
  16. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/__init__.py +0 -0
  17. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/__main__.py +0 -0
  18. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/actions.py +0 -0
  19. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/activity/__init__.py +0 -0
  20. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/activity/log.py +0 -0
  21. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/agents/__init__.py +0 -0
  22. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/agents/child_tools.py +0 -0
  23. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/agents/fake_sdk_adapter.py +0 -0
  24. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/agents/manager.py +0 -0
  25. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/agents/permission_grants.py +0 -0
  26. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/agents/permission_inbox.py +0 -0
  27. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/agents/request_inbox.py +0 -0
  28. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/agents/sdk_adapter.py +0 -0
  29. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/agents/sort.py +0 -0
  30. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/agents/state.py +0 -0
  31. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/config.py +0 -0
  32. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/events.py +0 -0
  33. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/layout/__init__.py +0 -0
  34. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/layout/custom_widgets.py +0 -0
  35. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/layout/defaults.py +0 -0
  36. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/layout/engine.py +0 -0
  37. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/layout/local_widgets.py +0 -0
  38. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/layout/registry.py +0 -0
  39. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/layout/spec.py +0 -0
  40. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/layout/splitter.py +0 -0
  41. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/layout/titles.py +0 -0
  42. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/orchestrator/__init__.py +0 -0
  43. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/orchestrator/formatting.py +0 -0
  44. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/orchestrator/tabs_tools.py +0 -0
  45. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/orchestrator/tools.py +0 -0
  46. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/persistence/__init__.py +0 -0
  47. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/persistence/agents_index.py +0 -0
  48. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/persistence/atomic.py +0 -0
  49. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/persistence/layout_store.py +0 -0
  50. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/persistence/layouts_store.py +0 -0
  51. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/persistence/orchestrator_sessions.py +0 -0
  52. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/persistence/paths.py +0 -0
  53. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/persistence/themes_store.py +0 -0
  54. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/persistence/transcript_store.py +0 -0
  55. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/persistence/workspace_store.py +0 -0
  56. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/theme/__init__.py +0 -0
  57. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/theme/engine.py +0 -0
  58. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/theme/spec.py +0 -0
  59. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/widgets/__init__.py +0 -0
  60. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/widgets/_file_lang.py +0 -0
  61. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/widgets/_terminal_keys.py +0 -0
  62. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/widgets/_terminal_render.py +0 -0
  63. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/widgets/activity_feed.py +0 -0
  64. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/widgets/agent_table.py +0 -0
  65. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/widgets/change_cwd_screen.py +0 -0
  66. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/widgets/diff_viewer.py +0 -0
  67. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/widgets/file_editor.py +0 -0
  68. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/widgets/file_tree.py +0 -0
  69. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/widgets/file_viewer.py +0 -0
  70. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/widgets/history_screen.py +0 -0
  71. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/widgets/layout_switcher.py +0 -0
  72. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/widgets/log_tail.py +0 -0
  73. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/widgets/markdown.py +0 -0
  74. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/widgets/new_tab_screen.py +0 -0
  75. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/widgets/notebook.py +0 -0
  76. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/widgets/permission_modal.py +0 -0
  77. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/widgets/permission_request_bar.py +0 -0
  78. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/widgets/resume_screen.py +0 -0
  79. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/widgets/rich_transcript.py +0 -0
  80. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/widgets/system_usage.py +0 -0
  81. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/widgets/terminal.py +0 -0
  82. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/widgets/theme_switcher.py +0 -0
  83. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/widgets/transcript_screen.py +0 -0
  84. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/workspace/__init__.py +0 -0
  85. {patchfeld-0.2.1 → patchfeld-0.2.3}/patchfeld/workspace/spec.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: patchfeld
3
- Version: 0.2.1
3
+ Version: 0.2.3
4
4
  Summary: A Textual TUI for managing multiple Claude Code agent sessions
5
5
  Project-URL: Homepage, https://github.com/jimmymills/patchfeld
6
6
  Project-URL: Repository, https://github.com/jimmymills/patchfeld
@@ -39,6 +39,8 @@ Description-Content-Type: text/markdown
39
39
  orchestrator-managed workspace — and lets the agent reshape the UI to fit
40
40
  the work.**
41
41
 
42
+ **[patchfeld.com](https://patchfeld.com)** · [PyPI](https://pypi.org/project/patchfeld/) · [GitHub](https://github.com/jimmymills/patchfeld)
43
+
42
44
  ![patchfeld — orchestrator chat on the left, agent table and activity feed on the right](https://raw.githubusercontent.com/jimmymills/patchfeld/main/docs/images/screenshot.png)
43
45
 
44
46
  ## The pitch
@@ -48,7 +50,7 @@ one's writing tests, one's doing a security pass. They live in three
48
50
  terminal tabs with three scrollbacks, and you're the one mentally juggling
49
51
  which is waiting on what.
50
52
 
51
- **Patchfeld is the room you wish you had.** One TUI. One top-level Claude —
53
+ **Patchfeld is a studio for orchestrating agents.** One TUI. One top-level Claude —
52
54
  the *orchestrator* — runs the show. You tell it what you want done in
53
55
  plain English; it spawns the right children with the right tool
54
56
  allowlists, watches their progress, and pulls them onscreen when they
@@ -118,7 +120,7 @@ the ideas.
118
120
  the actual `claude` CLI, or your shell, in any panel. Mode-C custom
119
121
  widgets let the orchestrator ship Python at runtime when the curated
120
122
  widget library isn't enough.
121
- - **Approve tool calls without leaving the room.** When a child wants
123
+ - **Approve tool calls without leaving the workspace.** When a child wants
122
124
  to use a tool that isn't auto-approved, a modal pops in patchfeld with
123
125
  the tool name and full arguments. Approve once, deny once, always
124
126
  allow this tool for any agent named X (persisted to disk), or always
@@ -4,6 +4,8 @@
4
4
  orchestrator-managed workspace — and lets the agent reshape the UI to fit
5
5
  the work.**
6
6
 
7
+ **[patchfeld.com](https://patchfeld.com)** · [PyPI](https://pypi.org/project/patchfeld/) · [GitHub](https://github.com/jimmymills/patchfeld)
8
+
7
9
  ![patchfeld — orchestrator chat on the left, agent table and activity feed on the right](https://raw.githubusercontent.com/jimmymills/patchfeld/main/docs/images/screenshot.png)
8
10
 
9
11
  ## The pitch
@@ -13,7 +15,7 @@ one's writing tests, one's doing a security pass. They live in three
13
15
  terminal tabs with three scrollbacks, and you're the one mentally juggling
14
16
  which is waiting on what.
15
17
 
16
- **Patchfeld is the room you wish you had.** One TUI. One top-level Claude —
18
+ **Patchfeld is a studio for orchestrating agents.** One TUI. One top-level Claude —
17
19
  the *orchestrator* — runs the show. You tell it what you want done in
18
20
  plain English; it spawns the right children with the right tool
19
21
  allowlists, watches their progress, and pulls them onscreen when they
@@ -83,7 +85,7 @@ the ideas.
83
85
  the actual `claude` CLI, or your shell, in any panel. Mode-C custom
84
86
  widgets let the orchestrator ship Python at runtime when the curated
85
87
  widget library isn't enough.
86
- - **Approve tool calls without leaving the room.** When a child wants
88
+ - **Approve tool calls without leaving the workspace.** When a child wants
87
89
  to use a tool that isn't auto-approved, a modal pops in patchfeld with
88
90
  the tool name and full arguments. Approve once, deny once, always
89
91
  allow this tool for any agent named X (persisted to disk), or always
@@ -50,6 +50,13 @@ class AgentSession:
50
50
  self._send_lock = asyncio.Lock()
51
51
  self._pre_wait_state: AgentState | None = None
52
52
  self._pre_perm_state: AgentState | None = None
53
+ # Tasks created by queue_send() that are still pending or in-flight.
54
+ # Tracked so interrupt() can cancel them before they call
55
+ # adapter.query — otherwise a DirectMessageToAgent queued behind
56
+ # the active stream (e.g. an orchestrator's send_to_agent payload)
57
+ # would wake up the moment the SDK signals end-of-stream and run
58
+ # the queued prompt against the now-interrupted session.
59
+ self._queued_send_tasks: list[asyncio.Task] = []
53
60
 
54
61
  @property
55
62
  def session_id(self) -> str | None:
@@ -79,14 +86,37 @@ class AgentSession:
79
86
  in the same task will correctly block until the send completes —
80
87
  without it, wait_idle could return before the send task acquires the
81
88
  send lock.
89
+
90
+ The task is also tracked so `interrupt()` can cancel it if the user
91
+ interrupts before it has issued its query.
82
92
  """
83
93
  self._idle_event.clear()
84
- return asyncio.create_task(self.send(prompt))
94
+ task = asyncio.create_task(self.send(prompt))
95
+ # Drop completed tasks before appending so the list doesn't grow.
96
+ self._queued_send_tasks = [
97
+ t for t in self._queued_send_tasks if not t.done()
98
+ ]
99
+ self._queued_send_tasks.append(task)
100
+ task.add_done_callback(self._queued_send_tasks.remove)
101
+ return task
85
102
 
86
103
  async def wait_idle(self) -> None:
87
104
  await self._idle_event.wait()
88
105
 
89
106
  async def interrupt(self) -> None:
107
+ # Cancel any send tasks that are still queued (blocked behind the
108
+ # active stream) BEFORE signalling the SDK. Otherwise the SDK's
109
+ # end-of-stream wakes them up and they post their prompt against
110
+ # the now-interrupted session — which is how an orchestrator's
111
+ # `send_to_agent` payload was landing on the child agent after
112
+ # the user pressed ctrl+c.
113
+ pending = [t for t in self._queued_send_tasks if not t.done()]
114
+ for task in pending:
115
+ task.cancel()
116
+ # Wait for cancellations to propagate so the lock is released
117
+ # before the SDK starts winding down the stream.
118
+ if pending:
119
+ await asyncio.gather(*pending, return_exceptions=True)
90
120
  await self._adapter.interrupt()
91
121
 
92
122
  async def stop(self) -> None:
@@ -23,6 +23,8 @@ from patchfeld.layout.local_widgets import LocalWidgetLoader, LoadOutcome
23
23
  from patchfeld.layout.registry import WidgetRegistry
24
24
  from patchfeld.layout.spec import LayoutSpec
25
25
  from patchfeld.orchestrator.session import OrchestratorSession
26
+ from patchfeld.orchestrator.skills import SkillsIndex, default_skills_index
27
+ from patchfeld.orchestrator.slash_completion import SlashCompleter
26
28
  from patchfeld.persistence.layouts_store import NamedLayoutsStore
27
29
  from patchfeld.persistence.themes_store import NamedThemesStore
28
30
  from patchfeld.persistence.paths import global_config_dir, local_widgets_dir
@@ -340,6 +342,27 @@ class PatchfeldApp(App):
340
342
  # Unsub callable for the PermissionRequested handler; None when bypass mode.
341
343
  self._unsub_permission_requested: "callable | None" = None
342
344
 
345
+ # Discover locally-installed skills once at app construction. The
346
+ # set is reused on every orchestrator rebuild (after /cd or
347
+ # /bypass-permissions) so the user sees a stable list of `/<skill>`
348
+ # commands across sessions. To pick up a newly-installed skill,
349
+ # restart the app — same as the widget loader.
350
+ from patchfeld.orchestrator.session import (
351
+ _BUILTIN_COMMAND_NAMES as _ORCH_BUILTINS,
352
+ )
353
+ self._skills_index: SkillsIndex = default_skills_index(
354
+ builtin_command_names=_ORCH_BUILTINS,
355
+ )
356
+ # Tab-completion engine for `/`-prefixed commands. Both the top
357
+ # CommandBar and any OrchestratorChat input read this attribute
358
+ # via `getattr(self.app, "slash_completer", None)`. It's a snapshot
359
+ # taken once here so the candidate list is stable across orchestrator
360
+ # rebuilds (mirrors how _skills_index is reused above).
361
+ self.slash_completer: SlashCompleter = SlashCompleter.build(
362
+ builtin_commands=_ORCH_BUILTINS,
363
+ skills_index=self._skills_index,
364
+ )
365
+
343
366
  self.manager = manager or AgentManager(
344
367
  cwd=self.cwd,
345
368
  bus=self.event_bus,
@@ -360,6 +383,7 @@ class PatchfeldApp(App):
360
383
  current_layout=lambda: self._active_layout(),
361
384
  app=self,
362
385
  permission_grants=self._permission_grants,
386
+ skills=self._skills_index,
363
387
  )
364
388
  # Production opts in to LLM-summarized session titles.
365
389
  self.orchestrator._auto_title_enabled = True
@@ -1071,6 +1095,7 @@ class PatchfeldApp(App):
1071
1095
  current_layout=lambda: self._active_layout(),
1072
1096
  app=self,
1073
1097
  permission_grants=self._permission_grants,
1098
+ skills=self._skills_index,
1074
1099
  )
1075
1100
  self.orchestrator._auto_title_enabled = True
1076
1101
  await self.orchestrator.start()
@@ -1169,6 +1194,7 @@ class PatchfeldApp(App):
1169
1194
  current_layout=lambda: self._active_layout(),
1170
1195
  app=self,
1171
1196
  permission_grants=self._permission_grants,
1197
+ skills=self._skills_index,
1172
1198
  )
1173
1199
  self.orchestrator._auto_title_enabled = True
1174
1200
  await self.orchestrator.start()
@@ -1341,7 +1367,9 @@ class PatchfeldApp(App):
1341
1367
  # --- composition & lifecycle -------------------------------------------
1342
1368
 
1343
1369
  def compose(self) -> ComposeResult:
1344
- yield CommandBar(event_bus=self.event_bus)
1370
+ yield CommandBar(
1371
+ event_bus=self.event_bus, slash_completer=self.slash_completer,
1372
+ )
1345
1373
  yield TabbedContent(id="app-tabs")
1346
1374
  yield StatusBar(event_bus=self.event_bus)
1347
1375
 
@@ -38,6 +38,7 @@ from patchfeld.events import (
38
38
  UserMessageToOrchestrator,
39
39
  )
40
40
 
41
+ from patchfeld.orchestrator.skills import SkillsIndex
41
42
  from patchfeld.orchestrator.tools import build_orchestrator_mcp_server
42
43
  from patchfeld.persistence.orchestrator_sessions import (
43
44
  OrchestratorSessionEntry,
@@ -58,8 +59,23 @@ _HELP_RE = re.compile(r"^/help\s*$")
58
59
  _CD_RE = re.compile(r"^/cd\s+(.+?)\s*$")
59
60
  _BYPASS_PERMS_RE = re.compile(r"^/bypass-permissions\s*$")
60
61
  _REQUIRE_PERMS_RE = re.compile(r"^/require-permissions\s*$")
61
-
62
- _HELP_TEXT = (
62
+ # /<skill-name> <args...> — dispatch order is: built-in first, then skill,
63
+ # then unknown-slash error. Name alphabet matches `_SKILL_NAME_RE` in
64
+ # `patchfeld.orchestrator.skills`, anchored so we never match accidental
65
+ # leading-slash text like "/path/to/file".
66
+ _SKILL_RE = re.compile(r"^/([A-Za-z0-9][A-Za-z0-9_\-]*)(?:\s+(.*))?$")
67
+
68
+ # Names of all built-in slash commands. Used for two purposes:
69
+ # 1) Skill discovery logs a warning when a skill collides with one of these.
70
+ # 2) An "unknown slash command" reply must NOT fire for these — but in
71
+ # practice the explicit per-command regexes already handle them, so this
72
+ # list is informational. Keep it in sync with the regexes above.
73
+ _BUILTIN_COMMAND_NAMES: frozenset[str] = frozenset({
74
+ "reset", "resume", "rename", "help", "cd",
75
+ "bypass-permissions", "require-permissions",
76
+ })
77
+
78
+ _HELP_TEXT_BASE = (
63
79
  "Available commands:\n"
64
80
  " /reset Start a fresh orchestrator session\n"
65
81
  " /resume [<session_id>] Resume a past session (no arg → picker)\n"
@@ -70,6 +86,42 @@ _HELP_TEXT = (
70
86
  " /help Show this list"
71
87
  )
72
88
 
89
+
90
+ def _format_skill_invocation(name: str, args: str) -> str:
91
+ """Translate `/<skill> <args>` into a prose prompt that nudges the LLM
92
+ to call the `Skill` tool with `skill=<name>` and `args=<args>`.
93
+
94
+ Implementation note: the harness's `Skill` tool uses two parameters —
95
+ `skill` (the bare name) and `args` (a free-form string). We surface both
96
+ explicitly. We also tell the model to invoke the skill *immediately* so
97
+ it doesn't ask clarifying questions before reading the SKILL.md body.
98
+ """
99
+ if args:
100
+ return (
101
+ f"Use the `Skill` tool to invoke the `{name}` skill now. "
102
+ f"Pass the following text as the `args` parameter: {args}"
103
+ )
104
+ return (
105
+ f"Use the `Skill` tool to invoke the `{name}` skill now "
106
+ f"(no extra arguments)."
107
+ )
108
+
109
+
110
+ def _format_help_text(skills: SkillsIndex) -> str:
111
+ """Compose the /help reply: built-in commands plus a `Skills:` section
112
+ listing discovered skill names. The skills section is omitted entirely
113
+ when the index is empty so the output stays clean on bare installs."""
114
+ text = _HELP_TEXT_BASE
115
+ names = skills.names()
116
+ if not names:
117
+ return text
118
+ skills_line = "Skills: " + ", ".join(f"/{n}" for n in names)
119
+ note = (
120
+ " (run any with `/<name> <args>` to invoke. Built-in commands above "
121
+ "win on name collisions — collided skills are still reachable via prose.)"
122
+ )
123
+ return text + "\n\n" + skills_line + "\n" + note
124
+
73
125
  _TITLE_PROMPT = (
74
126
  "Summarize the following user message in 5-7 words for use as a session "
75
127
  "title. Respond with ONLY the title — no quotes, no punctuation, no "
@@ -137,6 +189,7 @@ class OrchestratorSession:
137
189
  current_layout=None,
138
190
  app=None,
139
191
  permission_grants: PermissionGrants | None = None,
192
+ skills: SkillsIndex | None = None,
140
193
  ) -> None:
141
194
  self._cwd = cwd
142
195
  self._bus = bus
@@ -179,6 +232,9 @@ class OrchestratorSession:
179
232
  self._auto_title_enabled: bool = False
180
233
  self._title_task: asyncio.Task | None = None
181
234
  self._grants = permission_grants
235
+ # Skills registry (immutable for the lifetime of the session). Empty
236
+ # default keeps tests and headless callers from having to pass one.
237
+ self._skills: SkillsIndex = skills if skills is not None else SkillsIndex()
182
238
  self._can_use_tool_callback: CanUseTool | None = None
183
239
  if permission_grants is not None:
184
240
  self._perm_inbox: PermissionInbox | None = PermissionInbox(
@@ -509,9 +565,36 @@ class OrchestratorSession:
509
565
  )
510
566
  return
511
567
  if _HELP_RE.match(text):
512
- self._publish_notice(_HELP_TEXT)
568
+ self._publish_notice(_format_help_text(self._skills))
569
+ return
570
+ # --- skill slash commands (after all built-ins) -------------------
571
+ # Built-ins win on name collisions: by the time we get here, every
572
+ # built-in regex has had its chance, so any `/name ...` left over is
573
+ # either a skill or unknown.
574
+ m = _SKILL_RE.match(text)
575
+ if m:
576
+ name = m.group(1)
577
+ args = (m.group(2) or "").strip()
578
+ entry = self._skills.get(name)
579
+ if entry is not None:
580
+ # Translate (design decision a): rewrite the slash line into a
581
+ # prose prompt that nudges the orchestrator's LLM to invoke
582
+ # the matching `Skill` tool. We don't synthesize a tool_use
583
+ # block ourselves — the SDK has no public seam for that.
584
+ self._send_tasks = [t for t in self._send_tasks if not t.done()]
585
+ rewritten = _format_skill_invocation(name, args)
586
+ task = self._inner.queue_send(rewritten)
587
+ self._send_tasks.append(task)
588
+ return
589
+ # Unknown slash — clear error rather than wasting tokens by
590
+ # forwarding `/<typo>` to the LLM as a raw prompt.
591
+ self._publish_notice(
592
+ f"unknown command or skill: /{name}. Try /help."
593
+ )
513
594
  return
514
- # Fall through: ordinary prompt.
595
+ # Fall through: ordinary prompt (no leading slash, or a slash followed
596
+ # by something that doesn't look like a skill name — e.g. the user
597
+ # pasted "/path/to/file" as part of a sentence).
515
598
  if self._current_session_first_message is None:
516
599
  self._current_session_first_message = text
517
600
  self._send_tasks = [t for t in self._send_tasks if not t.done()]
@@ -0,0 +1,193 @@
1
+ """Discovery + indexing of locally-installed Claude Code skills.
2
+
3
+ A "skill" lives at one of:
4
+
5
+ - `~/.claude/skills/<name>/SKILL.md`
6
+ User-installed skills. Highest priority on collisions.
7
+ - `~/.claude/plugins/cache/<plugin>/<version>/skills/<name>/SKILL.md`
8
+ Plugin-shipped skills. Walked second; first occurrence per bare name
9
+ wins (subsequent duplicates log a `collision` warning).
10
+
11
+ The orchestrator uses this index to expose `/<skill-name>` slash commands in
12
+ the chat input. Discovery is performed once at orchestrator-session start
13
+ (see `OrchestratorSession.__init__`) — re-scans require a restart, same as
14
+ the existing widget loader. Per the implementation plan we picked design (a)
15
+ "translate": when the user types `/<skill> <args>`, the orchestrator
16
+ synthesizes a prose prompt that nudges the LLM to invoke the matching `Skill`
17
+ tool. Discovery only needs the skill's *name* (not the body) — we never read
18
+ SKILL.md content here.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+ import re
25
+ from dataclasses import dataclass, field
26
+ from pathlib import Path
27
+ from typing import Iterable
28
+
29
+ log = logging.getLogger(__name__)
30
+
31
+ # Reused for the slash-command dispatch — only names matching this pattern
32
+ # are exposed. Keeping it tight avoids weird shell-escaping edge cases. The
33
+ # orchestrator's _SKILL_RE is anchored on this same alphabet.
34
+ _SKILL_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_\-]*$")
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class SkillEntry:
39
+ """A discovered skill the orchestrator can route slash commands to."""
40
+
41
+ name: str
42
+ """Bare skill name (the directory name, e.g. `kb-query`)."""
43
+
44
+ path: str
45
+ """Absolute path to the `SKILL.md` file. Stored for diagnostics; we don't
46
+ need to read the body to invoke a skill via the `Skill` tool."""
47
+
48
+ source: str
49
+ """Where the skill was found: `"user"` for `~/.claude/skills/<name>` or
50
+ `"plugin"` for `~/.claude/plugins/cache/.../<name>`. Used for logging
51
+ and for collision-resolution rules (user wins)."""
52
+
53
+
54
+ @dataclass
55
+ class SkillsIndex:
56
+ """Bare-name → SkillEntry, populated by `discover_skills`."""
57
+
58
+ entries: dict[str, SkillEntry] = field(default_factory=dict)
59
+
60
+ def get(self, name: str) -> SkillEntry | None:
61
+ return self.entries.get(name)
62
+
63
+ def names(self) -> list[str]:
64
+ """Lexicographically-sorted skill names. Used by `/help` so output is
65
+ deterministic regardless of filesystem walk order."""
66
+ return sorted(self.entries.keys())
67
+
68
+ def __contains__(self, name: object) -> bool:
69
+ return isinstance(name, str) and name in self.entries
70
+
71
+
72
+ def discover_skills(
73
+ *,
74
+ user_skills_dir: Path | None,
75
+ plugin_cache_dir: Path | None,
76
+ builtin_command_names: Iterable[str] = (),
77
+ ) -> SkillsIndex:
78
+ """Walk known locations, return a `SkillsIndex`.
79
+
80
+ Parameters
81
+ ----------
82
+ user_skills_dir : Path | None
83
+ Typically `~/.claude/skills`. If None or missing, skipped.
84
+ plugin_cache_dir : Path | None
85
+ Typically `~/.claude/plugins/cache`. If None or missing, skipped.
86
+ builtin_command_names : iterable of str
87
+ Names of orchestrator-internal slash commands (e.g. `cd`, `help`).
88
+ These do NOT cause the skill to be excluded — built-ins win at
89
+ dispatch time and the skill remains reachable via prose. We just
90
+ log a warning here so it's visible in production logs.
91
+
92
+ Notes
93
+ -----
94
+ - Each skill must live in its own directory containing a `SKILL.md` file.
95
+ We do not read the SKILL.md frontmatter — Claude Code's `Skill` tool
96
+ uses the directory name as the canonical identifier, and that's what
97
+ the orchestrator passes through.
98
+ - Walk order: user dir first (so user-installed copies win on collision),
99
+ then plugin cache. The plugin walk is bounded to depth 4 from the
100
+ cache root: `<plugin>/<version>/skills/<name>/SKILL.md`.
101
+ - Plugin-namespaced skills (e.g. `Notion:search`) are exposed under
102
+ their bare name (`search`). On collisions the first occurrence wins
103
+ — typically driven by directory iteration order, which is filesystem
104
+ dependent. A warning identifies the loser so a user can rename if
105
+ they care which copy is reachable via slash.
106
+ """
107
+ builtin_set = set(builtin_command_names)
108
+ entries: dict[str, SkillEntry] = {}
109
+
110
+ def _try_add(name: str, path: Path, source: str) -> None:
111
+ if not _SKILL_NAME_RE.match(name):
112
+ log.debug("skipping skill with non-slash-safe name: %r at %s",
113
+ name, path)
114
+ return
115
+ if name in entries:
116
+ existing = entries[name]
117
+ log.warning(
118
+ "skill name collision: %r (kept %s copy at %s; "
119
+ "ignored %s copy at %s)",
120
+ name, existing.source, existing.path, source, path,
121
+ )
122
+ return
123
+ if name in builtin_set:
124
+ log.warning(
125
+ "skill name %r collides with a built-in slash command; the "
126
+ "built-in wins at dispatch but the skill remains reachable "
127
+ "via prose. (path=%s, source=%s)",
128
+ name, path, source,
129
+ )
130
+ entries[name] = SkillEntry(
131
+ name=name, path=str(path), source=source,
132
+ )
133
+
134
+ # --- user dir ---------------------------------------------------------
135
+ if user_skills_dir is not None and user_skills_dir.is_dir():
136
+ for child in sorted(user_skills_dir.iterdir()):
137
+ if not child.is_dir():
138
+ continue
139
+ skill_md = child / "SKILL.md"
140
+ if not skill_md.is_file():
141
+ continue
142
+ _try_add(child.name, skill_md, source="user")
143
+
144
+ # --- plugin cache -----------------------------------------------------
145
+ if plugin_cache_dir is not None and plugin_cache_dir.is_dir():
146
+ # Layout: <cache>/<vendor>/<plugin>/<version>/skills/<skill_name>/SKILL.md
147
+ # Example: ~/.claude/plugins/cache/claude-plugins-official/superpowers/
148
+ # 5.1.0/skills/writing-plans/SKILL.md
149
+ # Per-plugin we pick the *highest lexicographic version directory*
150
+ # — for typical semver this lines up with the latest release (5.1.0
151
+ # > 5.0.7 lexicographically). Edge cases (5.10.0 vs 5.2.0) sort
152
+ # incorrectly under this rule; that's a known v1 limitation, and
153
+ # the workaround is to delete stale cache versions.
154
+ for vendor_dir in sorted(plugin_cache_dir.iterdir()):
155
+ if not vendor_dir.is_dir():
156
+ continue
157
+ for plugin_dir in sorted(vendor_dir.iterdir()):
158
+ if not plugin_dir.is_dir():
159
+ continue
160
+ version_dirs = sorted(
161
+ [d for d in plugin_dir.iterdir() if d.is_dir()],
162
+ )
163
+ if not version_dirs:
164
+ continue
165
+ # Highest version wins.
166
+ version_dir = version_dirs[-1]
167
+ skills_root = version_dir / "skills"
168
+ if not skills_root.is_dir():
169
+ continue
170
+ for skill_dir in sorted(skills_root.iterdir()):
171
+ if not skill_dir.is_dir():
172
+ continue
173
+ skill_md = skill_dir / "SKILL.md"
174
+ if not skill_md.is_file():
175
+ continue
176
+ _try_add(skill_dir.name, skill_md, source="plugin")
177
+
178
+ log.info("discovered %d skill(s): %s",
179
+ len(entries), sorted(entries.keys()))
180
+ return SkillsIndex(entries=entries)
181
+
182
+
183
+ def default_skills_index(builtin_command_names: Iterable[str] = ()) -> SkillsIndex:
184
+ """Convenience wrapper that scans the canonical user paths.
185
+
186
+ Used by production wiring; tests pass a hand-built `SkillsIndex` instead.
187
+ """
188
+ home = Path.home()
189
+ return discover_skills(
190
+ user_skills_dir=home / ".claude" / "skills",
191
+ plugin_cache_dir=home / ".claude" / "plugins" / "cache",
192
+ builtin_command_names=builtin_command_names,
193
+ )