one-code-extension 0.1.4 → 0.3.0

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 (300) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +200 -172
  3. package/agents/explore.md +9 -6
  4. package/agents/general-purpose.md +1 -1
  5. package/agents/plan.md +6 -4
  6. package/docs/guide/README.md +52 -0
  7. package/docs/guide/bring-your-claude-code-setup.md +126 -0
  8. package/docs/guide/configuration.md +171 -0
  9. package/docs/guide/differences-from-claude-code.md +150 -0
  10. package/docs/guide/doctor.md +86 -0
  11. package/docs/guide/hooks.md +109 -0
  12. package/docs/guide/installation.md +124 -0
  13. package/docs/guide/permissions-modes-and-auto-mode.md +450 -0
  14. package/docs/guide/providers-and-models.md +194 -0
  15. package/docs/guide/reference.md +224 -0
  16. package/docs/guide/sessions-and-context.md +190 -0
  17. package/docs/guide/skills-plugins-and-mcp.md +176 -0
  18. package/docs/guide/subagents-and-workflows.md +193 -0
  19. package/docs/guide/tasks-and-background-work.md +111 -0
  20. package/docs/guide/terminal-interface.md +155 -0
  21. package/docs/guide/tools.md +175 -0
  22. package/docs/guide/troubleshooting.md +175 -0
  23. package/extensions/ask-user/index.ts +65 -71
  24. package/extensions/ask-user/questions.ts +15 -28
  25. package/extensions/ask-user/widget.ts +533 -0
  26. package/extensions/auto-mode/actions.ts +9 -26
  27. package/extensions/auto-mode/classifier-prompt.ts +127 -0
  28. package/extensions/auto-mode/classifier.ts +305 -227
  29. package/extensions/auto-mode/config.ts +279 -77
  30. package/extensions/auto-mode/defaults.ts +17 -60
  31. package/extensions/auto-mode/instructions.ts +2 -1
  32. package/extensions/auto-mode/model-picker.ts +53 -31
  33. package/extensions/auto-mode/model-select.ts +153 -82
  34. package/extensions/auto-mode/paths.ts +13 -1
  35. package/extensions/auto-mode/pause.ts +8 -0
  36. package/extensions/auto-mode/prompt.ts +205 -238
  37. package/extensions/auto-mode/recoverability.ts +229 -0
  38. package/extensions/auto-mode/rules.ts +92 -75
  39. package/extensions/auto-mode/safety-floor.ts +43 -12
  40. package/extensions/auto-mode/sensitive.ts +11 -0
  41. package/extensions/auto-mode/setup-run.ts +245 -0
  42. package/extensions/auto-mode/setup.ts +235 -0
  43. package/extensions/auto-mode/shell-analysis.ts +332 -26
  44. package/extensions/auto-mode/transcript.ts +102 -0
  45. package/extensions/background/index.ts +256 -43
  46. package/extensions/background/monitor-batch.ts +58 -0
  47. package/extensions/background/registry.ts +9 -0
  48. package/extensions/background/wakeup.ts +45 -0
  49. package/extensions/bash/background.ts +47 -22
  50. package/extensions/bash/guards.ts +229 -0
  51. package/extensions/bash/index.ts +81 -19
  52. package/extensions/branding/index.ts +44 -18
  53. package/extensions/branding/startup.ts +23 -8
  54. package/extensions/claude-compat/index.ts +17 -2
  55. package/extensions/claude-context/index.ts +41 -12
  56. package/extensions/clear/index.ts +27 -0
  57. package/extensions/compaction/fit.ts +121 -0
  58. package/extensions/compaction/index.ts +176 -71
  59. package/extensions/compaction/prompt.ts +21 -1
  60. package/extensions/context-budget/budget.ts +61 -0
  61. package/extensions/context-budget/index.ts +66 -0
  62. package/extensions/context-management/index.ts +40 -16
  63. package/extensions/doctor/build.ts +226 -0
  64. package/extensions/doctor/cli.ts +99 -0
  65. package/extensions/doctor/compat.ts +532 -0
  66. package/extensions/doctor/dependencies.ts +186 -0
  67. package/extensions/doctor/fix-prompt.ts +125 -0
  68. package/extensions/doctor/index.ts +308 -0
  69. package/extensions/doctor/models.ts +231 -0
  70. package/extensions/doctor/presets.ts +208 -0
  71. package/extensions/doctor/report.ts +189 -0
  72. package/extensions/doctor/update-lookup.ts +46 -0
  73. package/extensions/doctor/viewer.ts +124 -0
  74. package/extensions/effort/index.ts +41 -4
  75. package/extensions/effort/slider.ts +33 -2
  76. package/extensions/file-tracker/index.ts +143 -20
  77. package/extensions/file-tracker/replay.ts +46 -0
  78. package/extensions/file-tracker/tracker.ts +51 -4
  79. package/extensions/footer/footer-line.ts +207 -0
  80. package/extensions/footer/index.ts +152 -0
  81. package/extensions/footer/pr.ts +53 -0
  82. package/extensions/hooks/executor.ts +57 -20
  83. package/extensions/hooks/formatter-notice.ts +76 -0
  84. package/extensions/hooks/index.ts +314 -73
  85. package/extensions/hooks/matcher.ts +35 -1
  86. package/extensions/hooks/plugin-hooks.ts +3 -3
  87. package/extensions/hooks/protocol.ts +58 -0
  88. package/extensions/hooks/subagent-bridge.ts +77 -0
  89. package/extensions/hooks/trust.ts +15 -4
  90. package/extensions/init/index.ts +26 -0
  91. package/extensions/init/prompt.ts +40 -0
  92. package/extensions/interrupted/index.ts +35 -0
  93. package/extensions/interrupted/line.ts +9 -0
  94. package/extensions/lib/abort.ts +15 -0
  95. package/extensions/lib/agent-loader.ts +183 -0
  96. package/extensions/lib/anthropic-payload.ts +15 -0
  97. package/extensions/lib/atomic-write.ts +56 -0
  98. package/extensions/lib/bridge-watch.ts +20 -0
  99. package/extensions/lib/cache-retention.ts +60 -0
  100. package/extensions/lib/capability-index.ts +466 -0
  101. package/extensions/lib/claude-context.ts +318 -27
  102. package/extensions/lib/claude-settings.ts +64 -0
  103. package/extensions/lib/consent-preview.ts +30 -0
  104. package/extensions/lib/deferred.ts +144 -13
  105. package/extensions/lib/favorites.ts +43 -0
  106. package/extensions/lib/fetch-timeout.ts +15 -0
  107. package/extensions/lib/frontmatter.ts +31 -0
  108. package/extensions/lib/git.ts +44 -2
  109. package/extensions/lib/hook-gate.ts +90 -0
  110. package/extensions/lib/interrupt.ts +94 -0
  111. package/extensions/lib/mcp-overrides.ts +61 -0
  112. package/extensions/lib/mcp-share.ts +25 -0
  113. package/extensions/lib/mcp-status.ts +34 -0
  114. package/extensions/lib/memory.ts +41 -6
  115. package/extensions/lib/model-facts.generated.json +2480 -0
  116. package/extensions/lib/model-facts.ts +162 -0
  117. package/extensions/lib/model-policy.ts +145 -189
  118. package/extensions/lib/model-tier.ts +480 -64
  119. package/extensions/lib/model-unusable.ts +37 -0
  120. package/extensions/lib/notifications.ts +320 -1
  121. package/extensions/lib/one-code-settings.ts +63 -0
  122. package/extensions/lib/original-command.ts +71 -0
  123. package/extensions/lib/package-version.ts +31 -0
  124. package/extensions/lib/paths.ts +39 -4
  125. package/extensions/lib/permission-gate.ts +192 -0
  126. package/extensions/lib/persisted-output.ts +18 -0
  127. package/extensions/lib/pi-ai-estimate.ts +160 -0
  128. package/extensions/lib/pi-version.ts +1 -1
  129. package/extensions/lib/plan-mode-channels.ts +11 -0
  130. package/extensions/lib/plugin-overrides.ts +24 -0
  131. package/extensions/lib/plugin-root.ts +44 -0
  132. package/extensions/lib/plugins.ts +233 -54
  133. package/extensions/lib/prefix-warm-gate.ts +157 -0
  134. package/extensions/lib/process-tree.ts +56 -0
  135. package/extensions/lib/reminders.ts +321 -68
  136. package/extensions/lib/scratchpad.ts +11 -8
  137. package/extensions/lib/session-lifecycle.ts +28 -0
  138. package/extensions/lib/settings-channels.ts +14 -0
  139. package/extensions/lib/shell-tasks.ts +54 -0
  140. package/extensions/lib/skill-overrides.ts +86 -0
  141. package/extensions/lib/skill-scan.ts +110 -0
  142. package/extensions/lib/text-width.ts +267 -0
  143. package/extensions/lib/tui-render.ts +281 -54
  144. package/extensions/lib/usage-bus.ts +75 -0
  145. package/extensions/lib/usage-tracker.ts +67 -0
  146. package/extensions/lib/worktree-isolation.ts +170 -0
  147. package/extensions/lsp/client.ts +69 -12
  148. package/extensions/lsp/format.ts +12 -2
  149. package/extensions/lsp/index.ts +273 -84
  150. package/extensions/lsp/install-hints.ts +43 -0
  151. package/extensions/lsp/keep-alive.ts +19 -0
  152. package/extensions/lsp/plugin-servers.ts +331 -0
  153. package/extensions/lsp/watcher.ts +157 -0
  154. package/extensions/mcp/announce.ts +99 -0
  155. package/extensions/mcp/client.ts +181 -22
  156. package/extensions/mcp/config.ts +55 -24
  157. package/extensions/mcp/index.ts +586 -86
  158. package/extensions/mcp/oauth/browser.ts +26 -0
  159. package/extensions/mcp/oauth/callback.ts +104 -0
  160. package/extensions/mcp/oauth/flow.ts +68 -0
  161. package/extensions/mcp/oauth/provider.ts +147 -0
  162. package/extensions/mcp/oauth/store.ts +86 -0
  163. package/extensions/mcp/panel/keys.ts +34 -0
  164. package/extensions/mcp/panel/model.ts +62 -0
  165. package/extensions/mcp/panel/render.ts +141 -0
  166. package/extensions/mcp/panel/state.ts +108 -0
  167. package/extensions/mcp/schema.ts +83 -6
  168. package/extensions/mcp/trust.ts +340 -0
  169. package/extensions/memory/entries.ts +163 -0
  170. package/extensions/memory/index.ts +119 -2
  171. package/extensions/memory/open-external.ts +97 -0
  172. package/extensions/memory/panel.ts +124 -0
  173. package/extensions/model-default/index.ts +74 -0
  174. package/extensions/notebook/index.ts +36 -18
  175. package/extensions/notebook/notebook.ts +110 -17
  176. package/extensions/permissions/index.ts +1012 -247
  177. package/extensions/permissions/matcher.ts +488 -57
  178. package/extensions/permissions/modes.ts +55 -7
  179. package/extensions/permissions/project-trust.ts +81 -0
  180. package/extensions/permissions/protected-paths.ts +16 -3
  181. package/extensions/permissions/rule-prose.ts +79 -0
  182. package/extensions/permissions/session-grant.ts +91 -0
  183. package/extensions/permissions/settings.ts +112 -33
  184. package/extensions/permissions/subagent-gate.ts +57 -0
  185. package/extensions/plan-mode/index.ts +143 -33
  186. package/extensions/plan-mode/reminder.ts +16 -19
  187. package/extensions/plan-mode/viewer.ts +40 -31
  188. package/extensions/plugins/counts.ts +68 -0
  189. package/extensions/plugins/index.ts +496 -32
  190. package/extensions/plugins/install/install.ts +167 -0
  191. package/extensions/plugins/install/paths.ts +23 -0
  192. package/extensions/plugins/install/registry.ts +72 -0
  193. package/extensions/plugins/marketplace/git.ts +103 -0
  194. package/extensions/plugins/marketplace/official.ts +36 -0
  195. package/extensions/plugins/marketplace/parse.ts +94 -0
  196. package/extensions/plugins/marketplace/registry.ts +60 -0
  197. package/extensions/plugins/marketplace/sync.ts +146 -0
  198. package/extensions/plugins/marketplace/types.ts +128 -0
  199. package/extensions/plugins/panel/keys.ts +52 -0
  200. package/extensions/plugins/panel/render.ts +315 -0
  201. package/extensions/plugins/panel/rows.ts +230 -0
  202. package/extensions/plugins/panel/state.ts +333 -0
  203. package/extensions/recap/index.ts +215 -0
  204. package/extensions/recap/prompt.ts +44 -0
  205. package/extensions/recap/scheduler.ts +87 -0
  206. package/extensions/search-tools/index.ts +0 -0
  207. package/extensions/search-tools/tools.ts +7 -5
  208. package/extensions/skill/index.ts +360 -34
  209. package/extensions/skill/invoke.ts +185 -0
  210. package/extensions/skill/listing.ts +21 -0
  211. package/extensions/skill/panel/keys.ts +41 -0
  212. package/extensions/skill/panel/render.ts +91 -0
  213. package/extensions/skill/panel/state.ts +115 -0
  214. package/extensions/spinner/index.ts +110 -0
  215. package/extensions/spinner/line.ts +51 -0
  216. package/extensions/spinner/verbs.ts +201 -0
  217. package/extensions/subagents/agents.ts +88 -44
  218. package/extensions/subagents/default-model.ts +62 -32
  219. package/extensions/subagents/delegation-steer.ts +23 -0
  220. package/extensions/subagents/hand-back-review.ts +67 -0
  221. package/extensions/subagents/index.ts +1545 -346
  222. package/extensions/subagents/live-runs.ts +293 -0
  223. package/extensions/subagents/model-select.ts +252 -82
  224. package/extensions/subagents/outcome.ts +90 -0
  225. package/extensions/subagents/panel-keys.ts +41 -0
  226. package/extensions/subagents/panel-render.ts +357 -0
  227. package/extensions/subagents/panel-widget.ts +330 -0
  228. package/extensions/subagents/pending-claim.ts +45 -0
  229. package/extensions/subagents/prose.ts +105 -0
  230. package/extensions/subagents/runner.ts +674 -0
  231. package/extensions/subagents/runs.ts +68 -1
  232. package/extensions/subagents/send-to-main-tool.ts +55 -0
  233. package/extensions/subagents/session-turns.ts +138 -0
  234. package/extensions/subagents/shell-panel.ts +198 -0
  235. package/extensions/subagents/usage.ts +16 -4
  236. package/extensions/subagents/worktree.ts +13 -0
  237. package/extensions/system-prompt/environment.ts +5 -3
  238. package/extensions/system-prompt/git-status.ts +93 -0
  239. package/extensions/system-prompt/index.ts +23 -1
  240. package/extensions/system-prompt/template.ts +34 -4
  241. package/extensions/system-prompt/tiers/common.ts +31 -6
  242. package/extensions/system-prompt/tiers/low.ts +13 -3
  243. package/extensions/system-prompt/tiers/mid.ts +21 -8
  244. package/extensions/system-reminder/index.ts +53 -9
  245. package/extensions/tasks/index.ts +65 -16
  246. package/extensions/tasks/store.ts +76 -3
  247. package/extensions/tool-search/announce.ts +42 -0
  248. package/extensions/tool-search/index.ts +136 -13
  249. package/extensions/turn-duration/index.ts +60 -0
  250. package/extensions/turn-duration/line.ts +22 -0
  251. package/extensions/turn-duration/span.ts +41 -0
  252. package/extensions/turn-duration/verbs.ts +24 -0
  253. package/extensions/web/backends.ts +421 -0
  254. package/extensions/web/index.ts +178 -21
  255. package/extensions/web-fetch/index.ts +74 -18
  256. package/extensions/web-fetch/summarize.ts +1 -1
  257. package/extensions/workflow/agent-session.ts +182 -112
  258. package/extensions/workflow/globals.ts +129 -106
  259. package/extensions/workflow/index.ts +343 -28
  260. package/extensions/workflow/records.ts +118 -0
  261. package/extensions/workflow/run-manager.ts +80 -31
  262. package/extensions/workflow/saved-workflows.ts +2 -1
  263. package/extensions/workflow/script-worker.mjs +110 -0
  264. package/extensions/workflow/types.ts +74 -8
  265. package/extensions/workflow/viewer.ts +562 -0
  266. package/extensions/workflow/vm-runtime.ts +118 -22
  267. package/extensions/workflow/widget.ts +126 -18
  268. package/extensions/worktree/guards.ts +240 -0
  269. package/extensions/worktree/index.ts +30 -5
  270. package/extensions/worktree/rewrite.ts +32 -16
  271. package/package.json +20 -10
  272. package/skills/code-review/SKILL.md +165 -0
  273. package/skills/fewer-permission-prompts/SKILL.md +61 -0
  274. package/skills/security-review/SKILL.md +178 -0
  275. package/skills/simplify/SKILL.md +70 -0
  276. package/themes/{one-code-light.json → onecode-light.json} +6 -1
  277. package/themes/{one-code.json → onecode.json} +6 -1
  278. package/types/app.d.ts +8 -1
  279. package/types/community-packages.d.ts +45 -2
  280. package/docs/decisions/auto-mode.md +0 -526
  281. package/docs/decisions/branding.md +0 -127
  282. package/docs/decisions/code-review-remediation.md +0 -106
  283. package/docs/decisions/compaction.md +0 -140
  284. package/docs/decisions/distribution.md +0 -168
  285. package/docs/decisions/hooks.md +0 -64
  286. package/docs/decisions/lsp.md +0 -58
  287. package/docs/decisions/mcp.md +0 -104
  288. package/docs/decisions/memory-state.md +0 -170
  289. package/docs/decisions/model-policy.md +0 -126
  290. package/docs/decisions/modes.md +0 -178
  291. package/docs/decisions/skills-plugins.md +0 -56
  292. package/docs/decisions/subagents-workflows.md +0 -225
  293. package/docs/decisions/system-prompt.md +0 -98
  294. package/docs/decisions/tools.md +0 -407
  295. package/docs/decisions/tui.md +0 -217
  296. package/docs/decisions.md +0 -148
  297. package/extensions/subagents/child.ts +0 -414
  298. package/extensions/subagents/rpc-turns.ts +0 -123
  299. package/extensions/todo/index.ts +0 -133
  300. package/extensions/workflow/permission-gate.ts +0 -71
@@ -1,36 +1,69 @@
1
1
  /**
2
2
  * Claude Code plugin discovery (pure).
3
3
  *
4
- * On-disk reality, read from a real installation:
4
+ * Two roots are merged:
5
5
  *
6
- * ~/.claude/plugins/installed_plugins.json
7
- * { "version": 2, "plugins": { "<name>@<marketplace>": [{ scope, installPath, version }] } }
6
+ * ~/.claude/plugins/ Claude Code's own installs — READ-ONLY.
7
+ * <agentDir>/plugins/ One Code's installs (lib/plugin-root.ts) —
8
+ * the only root One Code ever writes.
9
+ *
10
+ * Both hold the same on-disk format:
11
+ *
12
+ * <root>/installed_plugins.json
13
+ * { "version": 2, "plugins": { "<name>@<marketplace>": [{ scope, installPath, version, ... }] } }
8
14
  *
9
15
  * <installPath>/.claude-plugin/plugin.json { name, description, author }
10
16
  * <installPath>/agents/*.md agent definitions
11
17
  * <installPath>/commands/*.md slash commands
12
18
  * <installPath>/skills/<name>/SKILL.md skills
13
19
  * <installPath>/.mcp.json MCP servers
20
+ * <installPath>/.lsp.json LSP servers (extensions/lsp)
21
+ *
22
+ * Enabled state differs by origin: Claude Code keeps it in settings.json's
23
+ * `enabledPlugins` map (its installed_plugins.json entries have NO enabled
24
+ * field), overridable from One Code via <one-code root>/overrides.json;
25
+ * One Code's own entries carry `enabled` directly (we own that schema copy).
14
26
  *
15
27
  * Claude Code namespaces everything a plugin provides as `<plugin>:<name>`, so
16
28
  * two plugins can both ship a `commit` command.
17
29
  */
18
30
 
19
31
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
20
- import { basename, join } from "node:path";
32
+ import os from "node:os";
33
+ import { basename, join, resolve, sep } from "node:path";
34
+ import { readJsonFile } from "./atomic-write.ts";
35
+ import { readEnabledPlugins } from "./claude-settings.ts";
36
+ import { readOverrides } from "./plugin-overrides.ts";
37
+ import { pluginRoot } from "./plugin-root.ts";
38
+ import { isSkillEnabled, readSkillStates, skillOverrideKey } from "./skill-overrides.ts";
39
+ import { claudeUserDir } from "./paths.ts";
21
40
 
22
41
  export interface PluginManifest {
23
42
  name?: string;
24
43
  description?: string;
25
44
  }
26
45
 
46
+ export type PluginOrigin = "claude" | "one-code";
47
+
27
48
  export interface Plugin {
28
- /** Plugin name, without the @marketplace suffix. */
49
+ /** Raw registry key (`<name>@<marketplace>`) — what enabledPlugins/overrides key on. */
50
+ id: string;
51
+ /** Plugin name, without the @marketplace suffix (manifest name when present). */
29
52
  name: string;
30
53
  marketplace?: string;
31
54
  path: string;
32
55
  version?: string;
33
56
  description?: string;
57
+ /** Which root the plugin was discovered under. */
58
+ originRoot: PluginOrigin;
59
+ /** Effective enabled state after settings/override merging. */
60
+ enabled: boolean;
61
+ /** True when a claude-origin plugin's state comes from our overrides.json. */
62
+ overridden?: boolean;
63
+ /** Base dir for the plugin's `${CLAUDE_PLUGIN_DATA}` (per-origin). */
64
+ dataRoot: string;
65
+ /** Raw `enabled` from the installed_plugins.json entry (meaningful only for one-code origin). */
66
+ rawEnabled?: boolean;
34
67
  }
35
68
 
36
69
  export interface PluginResources {
@@ -40,15 +73,35 @@ export interface PluginResources {
40
73
  mcpConfig?: string;
41
74
  /** <plugin>/hooks/hooks.json when present — consumed by extensions/hooks. */
42
75
  hooksConfig?: string;
76
+ /** <plugin>/.lsp.json when present — consumed by extensions/lsp. */
77
+ lspConfig?: string;
43
78
  }
44
79
 
45
80
  interface InstalledEntry {
46
81
  scope?: string;
82
+ /** For scope "project"/"local": the project the plugin was installed for. */
83
+ projectPath?: string;
47
84
  installPath?: string;
48
85
  version?: string;
49
86
  enabled?: boolean;
50
87
  }
51
88
 
89
+ /**
90
+ * Whether an installed-plugin entry applies to the session cwd. A project- or
91
+ * local-scoped entry belongs to one project (its `projectPath`), so it should
92
+ * be live only in that project or a descendant — not in every project under One
93
+ * Code (review L6). A user-scoped entry (or one with no `projectPath`, or when
94
+ * cwd is unknown) applies everywhere.
95
+ */
96
+ export function entryAppliesToCwd(entry: InstalledEntry, cwd: string | undefined): boolean {
97
+ if (!cwd) return true;
98
+ if (entry.scope !== "project" && entry.scope !== "local") return true;
99
+ if (!entry.projectPath) return true;
100
+ const base = resolve(entry.projectPath);
101
+ const here = resolve(cwd);
102
+ return here === base || here.startsWith(base + sep);
103
+ }
104
+
52
105
  export function splitPluginKey(key: string): { name: string; marketplace?: string } {
53
106
  const at = key.lastIndexOf("@");
54
107
  if (at <= 0) return { name: key };
@@ -64,56 +117,80 @@ export function tryReadFile(path: string): string | undefined {
64
117
  }
65
118
  }
66
119
 
67
- function readJsonFile<T>(path: string): T | undefined {
68
- if (!existsSync(path)) return undefined;
69
- const raw = tryReadFile(path);
70
- if (raw === undefined) return undefined;
71
- try {
72
- return JSON.parse(raw) as T;
73
- } catch {
74
- return undefined;
75
- }
120
+ /** One root's installed plugins, before enabled-policy is applied. */
121
+ export interface InstalledPlugin {
122
+ id: string;
123
+ name: string;
124
+ marketplace?: string;
125
+ path: string;
126
+ version?: string;
127
+ description?: string;
128
+ rawEnabled?: boolean;
76
129
  }
77
130
 
78
- /** Reads the installed-plugin registry; the newest entry per plugin wins. */
79
- export function loadInstalledPlugins(claudeDir: string): Plugin[] {
131
+ /**
132
+ * Reads a root's installed-plugin registry (`<pluginsDir>/installed_plugins.json`).
133
+ * Format reader only — no enabled policy here: Claude Code's entries carry no
134
+ * enabled field (that state lives in settings.json), so the first entry with an
135
+ * existing installPath wins; a raw `enabled` value is passed through for the
136
+ * One Code root, where the entry schema is ours.
137
+ */
138
+ export function loadInstalledPlugins(pluginsDir: string, cwd?: string): InstalledPlugin[] {
80
139
  const registry = readJsonFile<{ plugins?: Record<string, InstalledEntry[]> }>(
81
- join(claudeDir, "plugins", "installed_plugins.json"),
140
+ join(pluginsDir, "installed_plugins.json"),
82
141
  );
83
142
  if (!registry?.plugins) return [];
84
143
 
85
- const plugins: Plugin[] = [];
144
+ const plugins: InstalledPlugin[] = [];
86
145
  for (const [key, entries] of Object.entries(registry.plugins)) {
87
146
  if (!Array.isArray(entries) || entries.length === 0) continue;
88
- const entry = entries.find((e) => e.enabled !== false && e.installPath);
89
- if (!entry?.installPath || !existsSync(entry.installPath)) continue;
147
+ const entry = entries.find((e) => e.installPath && existsSync(e.installPath) && entryAppliesToCwd(e, cwd));
148
+ if (!entry?.installPath) continue;
90
149
 
91
150
  const { name, marketplace } = splitPluginKey(key);
92
151
  const manifest = readJsonFile<PluginManifest>(join(entry.installPath, ".claude-plugin", "plugin.json"));
93
152
  plugins.push({
94
- name: manifest?.name || name,
153
+ id: key,
154
+ // A third-party manifest is copied in verbatim: a non-string `name`
155
+ // (or one carrying path separators) would later crash `localeCompare`
156
+ // or poison the namespace it becomes. Fall back to the registry key's
157
+ // name for anything not a clean string (review H2).
158
+ name: validPluginName(manifest?.name) ?? name,
95
159
  marketplace,
96
160
  path: entry.installPath,
97
161
  version: entry.version,
98
- description: manifest?.description,
162
+ description: typeof manifest?.description === "string" ? manifest.description : undefined,
163
+ rawEnabled: typeof entry.enabled === "boolean" ? entry.enabled : undefined,
99
164
  });
100
165
  }
101
166
  return plugins.sort((a, b) => a.name.localeCompare(b.name));
102
167
  }
103
168
 
104
- export function pluginResources(plugin: Plugin): PluginResources {
169
+ /** A manifest `name` usable as a namespace: a non-empty string free of path separators, colons and whitespace. */
170
+ export function validPluginName(value: unknown): string | undefined {
171
+ if (typeof value !== "string") return undefined;
172
+ const trimmed = value.trim();
173
+ if (trimmed.length === 0) return undefined;
174
+ if (/[/\\:\s]/.test(trimmed)) return undefined;
175
+ return trimmed;
176
+ }
177
+
178
+ export function pluginResources(plugin: Pick<Plugin, "path">): PluginResources {
105
179
  const dirIfExists = (name: string) => {
106
180
  const path = join(plugin.path, name);
107
181
  return existsSync(path) && statSync(path).isDirectory() ? path : undefined;
108
182
  };
109
- const mcpConfig = join(plugin.path, ".mcp.json");
110
- const hooksConfig = join(plugin.path, "hooks", "hooks.json");
183
+ const fileIfExists = (...segments: string[]) => {
184
+ const path = join(plugin.path, ...segments);
185
+ return existsSync(path) ? path : undefined;
186
+ };
111
187
  return {
112
188
  agentsDir: dirIfExists("agents"),
113
189
  commandsDir: dirIfExists("commands"),
114
190
  skillsDir: dirIfExists("skills"),
115
- mcpConfig: existsSync(mcpConfig) ? mcpConfig : undefined,
116
- hooksConfig: existsSync(hooksConfig) ? hooksConfig : undefined,
191
+ mcpConfig: fileIfExists(".mcp.json"),
192
+ hooksConfig: fileIfExists("hooks", "hooks.json"),
193
+ lspConfig: fileIfExists(".lsp.json"),
117
194
  };
118
195
  }
119
196
 
@@ -125,7 +202,7 @@ export interface PluginSkill {
125
202
  }
126
203
 
127
204
  /** Each immediate subdirectory holding a SKILL.md is one skill. */
128
- export function findPluginSkills(plugin: Plugin, skillsDir: string): PluginSkill[] {
205
+ export function findPluginSkills(plugin: Pick<Plugin, "name">, skillsDir: string): PluginSkill[] {
129
206
  const skills: PluginSkill[] = [];
130
207
  let entries: string[];
131
208
  try {
@@ -149,7 +226,7 @@ export interface PluginCommand {
149
226
  path: string;
150
227
  }
151
228
 
152
- export function findPluginCommands(plugin: Plugin, commandsDir: string): PluginCommand[] {
229
+ export function findPluginCommands(plugin: Pick<Plugin, "name">, commandsDir: string): PluginCommand[] {
153
230
  let entries: string[];
154
231
  try {
155
232
  entries = readdirSync(commandsDir);
@@ -165,14 +242,51 @@ export function findPluginCommands(plugin: Plugin, commandsDir: string): PluginC
165
242
  }));
166
243
  }
167
244
 
245
+ /** The two roots + settings locations discovery reads from. */
246
+ export interface DiscoverRoots {
247
+ /** Claude Code's plugins dir (`~/.claude/plugins`) — read-only. */
248
+ claudePluginsDir: string;
249
+ /** One Code's plugin root (`<agentDir>/plugins`) — ours to write. */
250
+ oneCodeRoot: string;
251
+ cwd: string;
252
+ home: string;
253
+ }
254
+
255
+ /** The standard roots; wiring passes `getAgentDir()` (pure modules can't). */
256
+ export function defaultDiscoverRoots(agentDir: string, cwd: string = process.cwd(), home: string = os.homedir()): DiscoverRoots {
257
+ return {
258
+ claudePluginsDir: join(claudeUserDir(home), "plugins"),
259
+ oneCodeRoot: pluginRoot(agentDir),
260
+ cwd,
261
+ home,
262
+ };
263
+ }
264
+
265
+ /**
266
+ * The enabled-state precedence for a Claude Code-installed plugin: a One Code
267
+ * override wins, then Claude Code's own settings, then default-enabled.
268
+ */
269
+ export function claudePluginEnabled(
270
+ id: string,
271
+ ccEnabled: Record<string, boolean>,
272
+ overrides: Record<string, boolean>,
273
+ ): boolean {
274
+ return overrides[id] ?? ccEnabled[id] ?? true;
275
+ }
276
+
277
+ /** One Code-installed plugins carry enabled on their own registry entry. */
278
+ export function oneCodePluginEnabled(rawEnabled: boolean | undefined): boolean {
279
+ return rawEnabled ?? true;
280
+ }
281
+
168
282
  /**
169
- * Everything a plugin contributes, resolved in one pass.
283
+ * Everything the installed plugins contribute, resolved in one pass.
170
284
  *
171
285
  * IMPORTANT: this is a function each consuming extension calls for itself, not a
172
286
  * shared singleton. Under jiti every extension file gets its own module
173
287
  * instance, so module-level state written by one extension is invisible to
174
- * another — a shared registry silently read as empty. Re-deriving is cheap (one
175
- * small JSON plus a few directory checks) and has no load-order constraints.
288
+ * another — a shared registry silently read as empty. Re-deriving is cheap (a
289
+ * few small JSON files plus directory checks) and has no load-order constraints.
176
290
  */
177
291
  export interface NamespacedDir {
178
292
  dir: string;
@@ -180,48 +294,113 @@ export interface NamespacedDir {
180
294
  }
181
295
 
182
296
  export interface DiscoveredPlugins {
297
+ /** Every installed plugin, enabled or not (the /plugins panel needs both). */
183
298
  plugins: Plugin[];
299
+ /** Enabled plugins only — the set whose resources are wired in below. */
300
+ enabledPlugins: Plugin[];
184
301
  agentDirs: NamespacedDir[];
185
302
  skills: PluginSkill[];
186
303
  commands: PluginCommand[];
187
304
  mcpConfigs: string[];
188
- /** Per-plugin summary, for status output. */
189
- byPlugin: Map<string, { agents: boolean; skills: number; commands: number; mcp: boolean }>;
305
+ /** The plugin each `mcpConfigs` path belongs to, so its servers can be namespaced `plugin:<name>:<server>`. */
306
+ mcpConfigPlugins: Map<string, string>;
307
+ /** Per-plugin summary keyed by plugin id, for status output. */
308
+ byPlugin: Map<string, { agents: boolean; skills: number; commands: number; mcp: boolean; lsp: boolean }>;
309
+ }
310
+
311
+ /**
312
+ * ONE discovery cache for the whole process. Seven extensions import this
313
+ * module (branding, hooks, lsp, mcp, plugins, skill, subagents) and jiti gives
314
+ * each its own module instance, so a module-level variable meant seven full
315
+ * disk scans at startup and an invalidation that cleared only the caller's
316
+ * copy. The cache therefore hangs off `globalThis` under a symbol: a pure
317
+ * memo of filesystem state (not steering state — the module-isolation rule in
318
+ * CLAUDE.md is about *that*), read-through, cleared everywhere at once.
319
+ */
320
+ const CACHE_SLOT = Symbol.for("one-code:plugins-discovery-cache");
321
+ type CacheBox = { current?: { key: string; result: DiscoveredPlugins } };
322
+ const box: CacheBox = ((globalThis as Record<symbol, unknown>)[CACHE_SLOT] ??= {}) as CacheBox;
323
+
324
+ /**
325
+ * Drops the process-wide discovery cache; the /plugins panel calls this after
326
+ * installs/toggles so every extension's next discoverPlugins() reflects them.
327
+ */
328
+ export function invalidatePluginsCache(): void {
329
+ box.current = undefined;
190
330
  }
191
331
 
192
- let cache: { claudeDir: string; result: DiscoveredPlugins } | undefined;
332
+ export function discoverPlugins(roots: DiscoverRoots): DiscoveredPlugins {
333
+ const key = [roots.claudePluginsDir, roots.oneCodeRoot, roots.cwd, roots.home].join("\n");
334
+ if (box.current?.key === key) return box.current.result;
335
+
336
+ const ccEnabled = readEnabledPlugins(roots.cwd, roots.home);
337
+ const overrides = readOverrides(roots.oneCodeRoot);
338
+ const skillOverrides = readSkillStates(roots.oneCodeRoot);
193
339
 
194
- export function discoverPlugins(claudeDir: string): DiscoveredPlugins {
195
- if (cache?.claudeDir === claudeDir) return cache.result;
340
+ const claudePlugins: Plugin[] = loadInstalledPlugins(roots.claudePluginsDir, roots.cwd).map((p) => ({
341
+ ...p,
342
+ originRoot: "claude" as const,
343
+ enabled: claudePluginEnabled(p.id, ccEnabled, overrides),
344
+ overridden: p.id in overrides,
345
+ dataRoot: join(roots.claudePluginsDir, "data"),
346
+ }));
347
+
348
+ const oneCodePlugins: Plugin[] = loadInstalledPlugins(roots.oneCodeRoot, roots.cwd).map((p) => ({
349
+ ...p,
350
+ originRoot: "one-code" as const,
351
+ enabled: oneCodePluginEnabled(p.rawEnabled),
352
+ dataRoot: join(roots.oneCodeRoot, "data"),
353
+ }));
354
+
355
+ // Same plugin id installed under both roots: the One Code copy wins — it's
356
+ // the one this harness actively manages.
357
+ const byId = new Map<string, Plugin>();
358
+ for (const plugin of [...claudePlugins, ...oneCodePlugins]) byId.set(plugin.id, plugin);
359
+ const plugins = [...byId.values()].sort((a, b) => a.name.localeCompare(b.name));
360
+ const enabledPlugins = plugins.filter((p) => p.enabled);
196
361
 
197
- const plugins = loadInstalledPlugins(claudeDir);
198
362
  const result: DiscoveredPlugins = {
199
363
  plugins,
364
+ enabledPlugins,
200
365
  agentDirs: [],
201
366
  skills: [],
202
367
  commands: [],
203
368
  mcpConfigs: [],
369
+ mcpConfigPlugins: new Map(),
204
370
  byPlugin: new Map(),
205
371
  };
206
372
 
207
373
  for (const plugin of plugins) {
208
- const resources = pluginResources(plugin);
209
- const skills = resources.skillsDir ? findPluginSkills(plugin, resources.skillsDir) : [];
210
- const commands = resources.commandsDir ? findPluginCommands(plugin, resources.commandsDir) : [];
211
-
212
- if (resources.agentsDir) result.agentDirs.push({ dir: resources.agentsDir, namespace: plugin.name });
213
- result.skills.push(...skills);
214
- result.commands.push(...commands);
215
- if (resources.mcpConfig) result.mcpConfigs.push(resources.mcpConfig);
216
-
217
- result.byPlugin.set(plugin.name, {
218
- agents: !!resources.agentsDir,
219
- skills: skills.length,
220
- commands: commands.length,
221
- mcp: !!resources.mcpConfig,
222
- });
374
+ // One plugin's fault (a broken symlink in its resource dirs, a statSync
375
+ // permission error) must skip only that plugin, not blank out every
376
+ // other plugin's skills/commands/agents for the session. safeDiscover's
377
+ // outer catch is the last resort; this keeps the blast radius to one entry.
378
+ try {
379
+ const resources = pluginResources(plugin);
380
+ const skills = resources.skillsDir ? findPluginSkills(plugin, resources.skillsDir) : [];
381
+ const commands = resources.commandsDir ? findPluginCommands(plugin, resources.commandsDir) : [];
382
+
383
+ result.byPlugin.set(plugin.id, {
384
+ agents: !!resources.agentsDir,
385
+ skills: skills.length,
386
+ commands: commands.length,
387
+ mcp: !!resources.mcpConfig,
388
+ lsp: !!resources.lspConfig,
389
+ });
390
+
391
+ if (!plugin.enabled) continue;
392
+ if (resources.agentsDir) result.agentDirs.push({ dir: resources.agentsDir, namespace: plugin.name });
393
+ result.skills.push(...skills.filter((s) => isSkillEnabled(skillOverrides, skillOverrideKey("plugin", s.name))));
394
+ result.commands.push(...commands);
395
+ if (resources.mcpConfig) {
396
+ result.mcpConfigs.push(resources.mcpConfig);
397
+ result.mcpConfigPlugins.set(resources.mcpConfig, plugin.name);
398
+ }
399
+ } catch (error) {
400
+ console.error(`One Code: skipping plugin "${plugin.id}" — its resources could not be read: ${(error as Error).message}`);
401
+ }
223
402
  }
224
403
 
225
- cache = { claudeDir, result };
404
+ box.current = { key, result };
226
405
  return result;
227
406
  }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * First-token gate for parallel agent fan-out.
3
+ *
4
+ * A provider cache entry becomes readable only once the request that writes
5
+ * it has started streaming. N children started at the same instant with a
6
+ * byte-identical prefix (same agent prompt, same first-message reminder stack,
7
+ * same model) therefore all pay the cache write; none reads what the others
8
+ * are writing. The gate lets the FIRST run for a prefix key go, holds the rest
9
+ * until that run's first assistant `message_start` (the stream has begun, the
10
+ * entry exists), then releases them — every follower reads the prefix instead
11
+ * of writing it. Followers of a key that recently streamed pass straight
12
+ * through; a leader that never streams (spawn failure, provider error) hands
13
+ * the lead to the next waiter; a leader that is merely slow times out so a
14
+ * stalled first request can never hold a whole fan-out.
15
+ *
16
+ * Pure (no pi imports); wired in subagents/runner.ts and workflow/agent-session.ts.
17
+ */
18
+
19
+ type Entry =
20
+ | { kind: "warming"; done: Promise<void>; resolve: () => void }
21
+ | { kind: "warm"; at: number };
22
+
23
+ /**
24
+ * The gate key for a child run: same prompt identity + cwd + model spec = same
25
+ * request prefix. Both fan-out sites (subagents/runner.ts, workflow/agent-session.ts)
26
+ * build their key here so the scheme cannot drift between them.
27
+ */
28
+ export function prefixWarmKey(promptIdentity: string, cwd: string, modelSpec: string | undefined): string {
29
+ return `${promptIdentity}|${cwd}|${modelSpec ?? ""}`;
30
+ }
31
+
32
+ /** Prompt identity of a fresh (non-fork) child: its agent definition, else the base prompt. */
33
+ export function agentPromptIdentity(agentName: string | undefined): string {
34
+ return agentName ? `agent:${agentName}` : "base";
35
+ }
36
+
37
+ export interface PrefixWarmGateOptions {
38
+ /** How long a leader may take to stream before waiters are released anyway. */
39
+ timeoutMs?: number;
40
+ /** How long after the last stream start a key counts as warm (the 5-minute TTL minus slack). */
41
+ warmForMs?: number;
42
+ /** Clock, for tests. */
43
+ now?: () => number;
44
+ }
45
+
46
+ /** What the caller reports when it is done with its admission. */
47
+ export type Release = (streamed: boolean) => void;
48
+
49
+ export class PrefixWarmGate {
50
+ private readonly entries = new Map<string, Entry>();
51
+ private readonly timeoutMs: number;
52
+ private readonly warmForMs: number;
53
+ private readonly now: () => number;
54
+
55
+ constructor(options: PrefixWarmGateOptions = {}) {
56
+ this.timeoutMs = options.timeoutMs ?? 20_000;
57
+ this.warmForMs = options.warmForMs ?? 4 * 60_000;
58
+ this.now = options.now ?? Date.now;
59
+ }
60
+
61
+ /**
62
+ * Wait until `key`'s prefix is (probably) cached, then return the release
63
+ * the caller must invoke: `release(true)` when its own response started
64
+ * streaming (refreshes the warm stamp; lets waiters go if it was the leader),
65
+ * `release(false)` if it gave up without streaming. Idempotent; the first
66
+ * call wins.
67
+ */
68
+ async admit(key: string): Promise<Release> {
69
+ for (;;) {
70
+ const entry = this.entries.get(key);
71
+ if (entry?.kind === "warm" && this.now() - entry.at < this.warmForMs) {
72
+ return this.followerRelease(key);
73
+ }
74
+ if (entry?.kind === "warming") {
75
+ await entry.done;
76
+ continue;
77
+ }
78
+ return this.lead(key);
79
+ }
80
+ }
81
+
82
+ /** How many keys are currently warming (for tests). */
83
+ get warmingCount(): number {
84
+ let count = 0;
85
+ for (const entry of this.entries.values()) if (entry.kind === "warming") count++;
86
+ return count;
87
+ }
88
+
89
+ private lead(key: string): Release {
90
+ let resolve!: () => void;
91
+ const done = new Promise<void>((r) => {
92
+ resolve = r;
93
+ });
94
+ const entry: Entry = { kind: "warming", done, resolve };
95
+ this.entries.set(key, entry);
96
+ // A leader that streams slowly must not hold the fan-out forever: on
97
+ // timeout everyone proceeds and the key is treated as warm.
98
+ const timer = setTimeout(() => {
99
+ if (this.entries.get(key) === entry) {
100
+ this.entries.set(key, { kind: "warm", at: this.now() });
101
+ resolve();
102
+ }
103
+ }, this.timeoutMs);
104
+ timer.unref?.();
105
+
106
+ let released = false;
107
+ return (streamed) => {
108
+ if (released) return;
109
+ released = true;
110
+ clearTimeout(timer);
111
+ if (this.entries.get(key) === entry) {
112
+ // Streamed: the entry exists, followers read it. Abandoned: back to
113
+ // cold, so the next waiter becomes the leader (its loop re-enters lead()).
114
+ if (streamed) this.entries.set(key, { kind: "warm", at: this.now() });
115
+ else this.entries.delete(key);
116
+ }
117
+ resolve();
118
+ };
119
+ }
120
+
121
+ /**
122
+ * `admit(key)` for an agent session: the session's own first assistant
123
+ * `message_start` (the stream has begun, the entry exists) releases it as
124
+ * streamed; the returned release is for the caller's `finally`, where
125
+ * `release(false)` is a no-op if streaming already released it and hands the
126
+ * lead on if the run never streamed. Structural session type keeps this
127
+ * module pi-free.
128
+ */
129
+ async admitOnFirstToken(
130
+ key: string,
131
+ session: { subscribe(listener: (event: unknown) => void): () => void },
132
+ ): Promise<Release> {
133
+ const release = await this.admit(key);
134
+ const stop = session.subscribe((event) => {
135
+ const e = event as { type?: string; message?: { role?: string } };
136
+ if (e.type === "message_start" && e.message?.role === "assistant") {
137
+ stop();
138
+ release(true);
139
+ }
140
+ });
141
+ return (streamed) => {
142
+ stop();
143
+ release(streamed);
144
+ };
145
+ }
146
+
147
+ private followerRelease(key: string): Release {
148
+ let released = false;
149
+ return (streamed) => {
150
+ if (released) return;
151
+ released = true;
152
+ if (!streamed) return;
153
+ const entry = this.entries.get(key);
154
+ if (entry?.kind === "warm") this.entries.set(key, { kind: "warm", at: this.now() });
155
+ };
156
+ }
157
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Process-tree termination for the shells One Code spawns in the background
3
+ * (`bash run_in_background`, `monitor`).
4
+ *
5
+ * A `$SHELL -c "cmd"` child is only the leader: with zsh (macOS default) a
6
+ * `cmd; echo` or a pipeline runs the command in further processes, and
7
+ * `child.kill()` signals the shell alone — the command lives on as an orphan
8
+ * holding the inherited stdout pipe, so `close` never fires (the task cannot
9
+ * finish, a `task_output block:true` waits its full timeout) and in a one-shot
10
+ * run the open pipe keeps node alive until the orphan exits
11
+ * (LIFECYCLE-REVIEW-2026-09-06 M2, measured: 3 min 21 s on a `tail -f | cat`).
12
+ *
13
+ * The producer spawns `detached` (own process group; `detachedSpawnOptions`)
14
+ * and stops it with `killProcessTree`, which signals the negative pid — the
15
+ * whole group — falling back to the child alone when the group is already gone.
16
+ * `stopProcessTree` adds the grace period: SIGTERM now, SIGKILL if the leader
17
+ * has not exited by then (a command that ignores SIGTERM still closes late,
18
+ * which is exactly the late callback the shutdown paths guard against).
19
+ */
20
+
21
+ import type { ChildProcess } from "node:child_process";
22
+
23
+ /** SIGTERM → SIGKILL grace for a stopped background tree (bash tasks, monitors). */
24
+ export const KILL_GRACE_MS = 2_000;
25
+
26
+ /** `detached` everywhere but Windows, where process groups do not exist. */
27
+ export function detachedSpawnOptions(): { detached: boolean } {
28
+ return { detached: process.platform !== "win32" };
29
+ }
30
+
31
+ export function killProcessTree(child: ChildProcess, signal: NodeJS.Signals = "SIGTERM"): void {
32
+ if (child.pid == null) return;
33
+ try {
34
+ process.kill(-child.pid, signal);
35
+ } catch {
36
+ try {
37
+ child.kill(signal);
38
+ } catch {
39
+ // Already gone.
40
+ }
41
+ }
42
+ }
43
+
44
+ /**
45
+ * SIGTERM the tree, then SIGKILL it after `graceMs` unless the leader exited.
46
+ * The timer is unref'd: it must never be what keeps a one-shot process alive.
47
+ */
48
+ export function stopProcessTree(child: ChildProcess, graceMs: number): void {
49
+ killProcessTree(child, "SIGTERM");
50
+ if (child.exitCode !== null || child.signalCode !== null) return;
51
+ const timer = setTimeout(() => {
52
+ if (child.exitCode === null && child.signalCode === null) killProcessTree(child, "SIGKILL");
53
+ }, graceMs);
54
+ timer.unref?.();
55
+ child.once("exit", () => clearTimeout(timer));
56
+ }