ur-agent 1.44.10 → 1.45.2

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.
@@ -71,12 +71,16 @@ ur auth gemini
71
71
  ur auth antigravity
72
72
  ur config set provider ollama
73
73
  ur config set provider openai-compatible
74
- ur config set model qwen3-coder:480b-cloud
74
+ ur config set model qwen2.5-coder:7b
75
75
  ur config set base_url http://localhost:11434/v1
76
76
  ur config set provider.fallback ollama
77
77
  ur upgrade
78
78
  ```
79
79
 
80
+ The optional `provider.fallback` value is used only to print an explicit
81
+ recovery recommendation in provider diagnostics. It never silently or
82
+ automatically changes the active provider.
83
+
80
84
  ## v1.25.x Additions
81
85
 
82
86
  | Addition | Surface | What it adds |
@@ -135,8 +139,8 @@ components instead of one giant prompt.
135
139
 
136
140
  | Addition | Surface | What it adds |
137
141
  | --- | --- | --- |
138
- | Agent skill runner | `src/services/agents/agentSkillRunner.ts` | Reusable wrapper around `startBackgroundTask({ worktree: true, pr: true })` that polls to completion and returns a PR-style summary. |
139
- | Worktree slash skills | `/debug-v2`, `/refactor`, `/paper-implementation`, `/benchmark`, `/security-review`, `/dockerize`, `/latex-paper` | Bundled slash skills that expand into prompts for isolated worktree work with clean commits and PR output. |
142
+ | Agent skill runner | `src/services/agents/agentSkillRunner.ts` | Reusable isolated-worktree wrapper that polls to completion; PR creation is available only with explicit `createPr: true`. |
143
+ | Worktree slash skills | `/debug-v2`, `/refactor`, `/paper-implementation`, `/benchmark`, `/security-review`, `/dockerize`, `/latex-paper`, `/batch` | Bundled slash skills keep changes local, run focused checks, ask before the final full suite, and never publish automatically. |
140
144
  | Agent templates | `ur agent-templates install` | Adds `debug-v2`, `refactor`, `paper-implementation`, `benchmark`, `security-review`, `dockerize`, and `latex-paper` reusable agent templates under `.ur/agents/`. |
141
145
  | Worktree command | `ur worktree list\|status\|clean` | Inspect and clean up UR agent worktrees created by `ur bg` or slash skills. |
142
146
 
@@ -58,6 +58,10 @@ ur provider select-model <provider> <model> --json
58
58
  ur config set base_url <url>
59
59
  ```
60
60
 
61
+ `provider.fallback` is diagnostic recovery metadata, not automatic routing.
62
+ When the active provider fails, `ur provider doctor` shows the configured
63
+ recovery command; changing providers remains an explicit user action.
64
+
61
65
  Provider values accept canonical IDs and common aliases. Examples:
62
66
  `openai-api`, `anthropic-api`, `gemini-api`, `openrouter`, `ollama`,
63
67
  `lmstudio`, `LM Studio`, `llama.cpp`, `vllm`, and the subscription CLI
@@ -88,6 +92,7 @@ it in the environment when you explicitly choose API mode:
88
92
 
89
93
  ```sh
90
94
  OPENAI_API_KEY=...
95
+ OPENAI_COMPATIBLE_API_KEY=...
91
96
  ANTHROPIC_API_KEY=...
92
97
  GEMINI_API_KEY=...
93
98
  OPENROUTER_API_KEY=...
@@ -117,8 +122,8 @@ ur --settings '{"ollama":{"host":"http://192.168.1.50:11434"}}'
117
122
  Model selection environment variables still work the same way:
118
123
 
119
124
  ```sh
120
- OLLAMA_MODEL=qwen3-coder:480b-cloud
121
- UR_MODEL=qwen3-coder:480b-cloud
125
+ OLLAMA_MODEL=qwen2.5-coder:7b
126
+ UR_MODEL=qwen2.5-coder:7b
122
127
  ```
123
128
 
124
129
  `OLLAMA_MODEL` selects the model name and takes precedence over `UR_MODEL` only
package/docs/IDE.md CHANGED
@@ -4,8 +4,7 @@ UR's professional IDE integration is the **UR Inline Diffs** VS Code extension,
4
4
  bundled in this repository (`extensions/vscode-ur-inline-diffs`). It gives VS
5
5
  Code, Cursor, and Windsurf a chat panel, inline diff review, an actions panel,
6
6
  an agent status card, a searchable command palette, and an agent options
7
- panel — all driven by the same `ur` CLI you already use from a terminal, with
8
- no separate server process and no new network surface.
7
+ panel — all driven by the same `ur` CLI you already use from a terminal.
9
8
 
10
9
  Other editors connect through different mechanisms, chosen per editor and
11
10
  stated honestly — nothing here claims support that does not exist:
@@ -19,12 +18,12 @@ stated honestly — nothing here claims support that does not exist:
19
18
  Neovim clients. This remains a separate transport from the VS Code
20
19
  extension's chat bridge below; the two are independent and neither depends
21
20
  on the other.
22
- - **Manual** — no auto-config; install a plugin and connect via `/ide`.
23
- **JetBrains is not implemented in this repository.** UR can detect whether
24
- a plugin named `ur-jetbrains-plugin` is installed
25
- (`src/utils/jetbrains.ts`), but this repo does not build, ship, or publish
26
- that plugin. Nothing in this document should be read as "install our
27
- JetBrains plugin today" — there isn't one yet.
21
+ - **JetBrains plugin** — the experimental bundled plugin under
22
+ `extensions/jetbrains-ur` connects to `ur acp serve` on loopback. It uses
23
+ JSON-RPC `initialize`, `session/new`, and `session/prompt` calls at `/acp`,
24
+ preserves a project-scoped session, and performs network work off the IDE UI
25
+ thread. It is installable from its `buildPlugin` zip; marketplace publishing
26
+ is not claimed.
28
27
 
29
28
  ## Supported targets
30
29
 
@@ -34,7 +33,7 @@ stated honestly — nothing here claims support that does not exist:
34
33
  | Cursor | UR Inline Diffs extension (VS Code fork) | `.vscode/settings.json` | Same extension as VS Code. |
35
34
  | Windsurf | UR Inline Diffs extension (VS Code fork) | `.vscode/settings.json` | Same extension as VS Code. |
36
35
  | Zed | stdio ACP | `.zed/settings.json` | Real Agent Client Protocol over stdio. |
37
- | JetBrains | not implemented | none | Detection code exists; no plugin ships from this repo. Use the CLI directly in the meantime. |
36
+ | JetBrains | bundled experimental plugin + HTTP ACP | manual zip install | Project-scoped JSON-RPC session and Send Selection action; synchronous ACP task result, not token streaming. |
38
37
  | Neovim | stdio ACP | snippet | Requires a third-party ACP client plugin. |
39
38
  | Generic ACP | stdio ACP / HTTP | snippet | `ur acp stdio` (native ACP) or `ur acp serve` (HTTP JSON-RPC; not a streaming transport — see Known limitations). |
40
39
 
@@ -128,7 +127,9 @@ rather than leaving it hanging.
128
127
  `.ur/ide/chat/` in the workspace (a manifest plus one file per session).
129
128
  **UR: Open Chat** can resume a previous session — using the CLI's own
130
129
  `--resume` — or start a new one; sessions survive closing and reopening VS
131
- Code.
130
+ Code. In multi-root workspaces the chat binds to the folder containing the
131
+ active editor, and stale callbacks from a canceled turn cannot mutate a newer
132
+ chat.
132
133
 
133
134
  **File and selection context.** **UR: Add Current File to Chat** and
134
135
  **UR: Add Selection to Chat** attach the active file or selection as explicit
@@ -200,8 +201,9 @@ success it did not observe.
200
201
 
201
202
  ## Known limitations
202
203
 
203
- - **JetBrains is not implemented.** No JetBrains plugin ships from this
204
- repository; see "Supported targets" above.
204
+ - **JetBrains integration is experimental and non-streaming.** It uses the
205
+ loopback HTTP ACP server and returns the completed synchronous task output;
206
+ it does not yet render token or tool-call streams.
205
207
  - **The lockfile/MCP IDE bridge is Phase 2 and not part of this MVP.** The
206
208
  CLI already implements the *client* half of a lockfile-discovered MCP
207
209
  connection to a running IDE (`src/utils/ide.ts`), used by the terminal
package/docs/USAGE.md CHANGED
@@ -13,7 +13,7 @@ Use interactive mode for iterative coding, debugging, research, and repository e
13
13
  Useful options:
14
14
 
15
15
  ```sh
16
- ur --model qwen3-coder:480b-cloud
16
+ ur --model qwen2.5-coder:7b
17
17
  ur --add-dir ../other-project
18
18
  ur --permission-mode ask
19
19
  ur --continue
@@ -63,7 +63,7 @@ Ollama router choose from the models exposed by your local Ollama app.
63
63
  You can also choose the model for a single session:
64
64
 
65
65
  ```sh
66
- ur --model qwen3-coder:480b-cloud
66
+ ur --model qwen2.5-coder:7b
67
67
  ur --model qwen2.5-coder:latest
68
68
  ```
69
69
 
@@ -108,6 +108,10 @@ ur config set base_url <url>
108
108
  ur config set provider.fallback ollama
109
109
  ```
110
110
 
111
+ `provider.fallback` only controls the recovery suggestion printed by provider
112
+ diagnostics. UR does not switch or retry across providers automatically; use
113
+ `ur config set provider <id>` after reviewing the failure.
114
+
111
115
  In the interactive app, `/model` chooses a provider first and then a model from
112
116
  that provider only. The saved pair controls the runtime backend for the next
113
117
  agent request. There is no cross-provider fallback: OpenAI API does not fall
package/docs/providers.md CHANGED
@@ -36,6 +36,7 @@ multimodal input, external CLI boundary, and sandbox scope:
36
36
  | Claude API | API | UR-native | no | yes | yes | yes | UR Bash/File sandbox | `api:anthropic` | `ANTHROPIC_API_KEY` |
37
37
  | Gemini API | API | UR-native | no | yes | yes | yes | UR Bash/File sandbox | `api:gemini` | `GEMINI_API_KEY` |
38
38
  | OpenRouter | API/router | UR-native | no | yes | yes | yes | UR Bash/File sandbox | `api:openrouter` | `OPENROUTER_API_KEY` |
39
+ | OpenAI-compatible | server/API | UR-native | no | yes | yes | endpoint-dependent | UR Bash/File sandbox | `openai-compatible` | optional `OPENAI_COMPATIBLE_API_KEY`; never reuses `OPENAI_API_KEY` |
39
40
  | Ollama | local | UR-native | no | yes | yes | yes* | UR Bash/File sandbox | `ollama` | localhost Ollama runtime |
40
41
  | LM Studio | local/server | UR-native | no | yes | yes | yes | UR Bash/File sandbox | `openai-compatible:lmstudio` | local OpenAI-compatible server |
41
42
  | llama.cpp | local/server | UR-native | no | yes | yes | yes | UR Bash/File sandbox | `openai-compatible:llama.cpp` | local OpenAI-compatible server |
@@ -106,6 +107,10 @@ ur config set base_url <url>
106
107
  ur config set provider.fallback ollama
107
108
  ```
108
109
 
110
+ The fallback setting is a recovery hint for `ur provider doctor`; it does not
111
+ route a failed request to another provider. Review the failure and use
112
+ `ur config set provider <id>` to switch explicitly.
113
+
109
114
  ## Provider-scoped model selection
110
115
 
111
116
  UR-Nexus shows providers first, then only models available for the selected provider. This prevents incompatible model/provider pairs and keeps API-key, local/server, subscription, and external app bridge model lists separate. The generic `subscription` entry has no models unless a real independent subscription runtime is configured; UR does not list fake subscription models.
@@ -185,7 +190,7 @@ After selecting a model, the confirmation shows:
185
190
 
186
191
  → Step 2: Select model
187
192
  llama3 · discovered from Ollama · live
188
- qwen3-coder:480b-cloud · discovered from Ollama · live
193
+ qwen2.5-coder:7b · discovered from Ollama · live
189
194
 
190
195
  → Select: llama3
191
196
 
@@ -401,6 +406,7 @@ API providers require explicit user selection and environment keys:
401
406
 
402
407
  ```sh
403
408
  OPENAI_API_KEY=...
409
+ OPENAI_COMPATIBLE_API_KEY=...
404
410
  ANTHROPIC_API_KEY=...
405
411
  GEMINI_API_KEY=...
406
412
  OPENROUTER_API_KEY=...
@@ -414,6 +420,10 @@ ur config set base_url http://localhost:1234/v1
414
420
  ur config set model local-model-name
415
421
  ```
416
422
 
423
+ `OPENAI_COMPATIBLE_API_KEY` is intentionally separate from
424
+ `OPENAI_API_KEY`; selecting an arbitrary compatible base URL never forwards
425
+ the OpenAI credential to that host.
426
+
417
427
  Local/server providers use their normal endpoints:
418
428
 
419
429
  - Ollama: `http://localhost:11434`
@@ -44,7 +44,7 @@
44
44
  <main id="content" class="content">
45
45
  <header class="topbar">
46
46
  <div>
47
- <p class="eyebrow">Version 1.44.10</p>
47
+ <p class="eyebrow">Version 1.45.2</p>
48
48
  <h1>UR-Nexus Documentation</h1>
49
49
  <p class="lead">A practical, tutorial-style reference for installing, configuring, automating, extending, and operating UR-Nexus.</p>
50
50
  </div>
@@ -0,0 +1,22 @@
1
+ # UR for JetBrains (experimental scaffold)
2
+
3
+ Connects JetBrains IDEs to a running UR-Nexus agent over the local ACP server
4
+ (`ur acp serve --port 9100`). Builds with the IntelliJ Platform Gradle Plugin 2.x (verified: `gradle buildPlugin` produces `build/distributions/jetbrains-ur-<version>.zip` against IDEA 2024.2). Install via Settings → Plugins → ⚙ → Install Plugin from Disk. Mirrors what `extensions/vscode-ur-inline-diffs` does for VS Code.
5
+
6
+ ## What it wires up
7
+
8
+ - A tool window ("UR Agent") that checks the loopback ACP endpoint without
9
+ blocking the IDE event thread.
10
+ - An action (Tools → UR: Send Selection) that posts the current selection as
11
+ a prompt to a project-scoped JSON-RPC session at `/acp`. The current server
12
+ returns a completed synchronous task result; token streaming is not claimed.
13
+
14
+ ## Build
15
+
16
+ ```
17
+ cd extensions/jetbrains-ur
18
+ JAVA_HOME=$(brew --prefix openjdk@17) gradle buildPlugin # → build/distributions/*.zip
19
+ ```
20
+
21
+ The heavy lifting stays in UR's ACP server — the plugin is intentionally a
22
+ thin client, so protocol changes land on the CLI side.
@@ -0,0 +1,34 @@
1
+ plugins {
2
+ id("java")
3
+ id("org.jetbrains.kotlin.jvm") version "2.0.21"
4
+ id("org.jetbrains.intellij.platform") version "2.7.2"
5
+ }
6
+
7
+ group = "dev.urnexus"
8
+ version = "1.45.2"
9
+
10
+ repositories {
11
+ mavenCentral()
12
+ intellijPlatform {
13
+ defaultRepositories()
14
+ }
15
+ }
16
+
17
+ dependencies {
18
+ implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
19
+ intellijPlatform {
20
+ intellijIdeaCommunity("2024.2.3")
21
+ }
22
+ }
23
+
24
+ intellijPlatform {
25
+ pluginConfiguration {
26
+ ideaVersion {
27
+ sinceBuild.set("242")
28
+ }
29
+ }
30
+ }
31
+
32
+ kotlin {
33
+ jvmToolchain(17)
34
+ }
@@ -0,0 +1 @@
1
+ rootProject.name = "jetbrains-ur"
@@ -0,0 +1,28 @@
1
+ package dev.urnexus
2
+
3
+ import com.intellij.openapi.actionSystem.AnAction
4
+ import com.intellij.openapi.actionSystem.AnActionEvent
5
+ import com.intellij.openapi.actionSystem.CommonDataKeys
6
+ import com.intellij.openapi.ui.Messages
7
+ import com.intellij.openapi.progress.ProgressIndicator
8
+ import com.intellij.openapi.progress.ProgressManager
9
+ import com.intellij.openapi.progress.Task
10
+ import com.intellij.openapi.application.ApplicationManager
11
+
12
+ class SendSelectionAction : AnAction() {
13
+ override fun actionPerformed(e: AnActionEvent) {
14
+ val editor = e.getData(CommonDataKeys.EDITOR) ?: return
15
+ val selection = editor.selectionModel.selectedText ?: return
16
+ val project = e.project ?: return
17
+ val client = project.getService(UrAcpClient::class.java)
18
+ ProgressManager.getInstance().run(object : Task.Backgroundable(project, "Sending selection to UR", true) {
19
+ override fun run(indicator: ProgressIndicator) {
20
+ val result = runCatching { client.sendPrompt(selection) }
21
+ ApplicationManager.getApplication().invokeLater {
22
+ result.onSuccess { Messages.showInfoMessage(project, it.take(2000), "UR Agent") }
23
+ .onFailure { Messages.showErrorDialog(project, it.message ?: it.toString(), "UR Agent") }
24
+ }
25
+ }
26
+ })
27
+ }
28
+ }
@@ -0,0 +1,97 @@
1
+ package dev.urnexus
2
+
3
+ import com.intellij.openapi.components.Service
4
+ import com.intellij.openapi.project.Project
5
+ import java.net.URI
6
+ import java.net.http.HttpClient
7
+ import java.net.http.HttpRequest
8
+ import java.net.http.HttpResponse
9
+ import java.time.Duration
10
+ import java.util.concurrent.atomic.AtomicLong
11
+ import kotlinx.serialization.json.Json
12
+ import kotlinx.serialization.json.JsonElement
13
+ import kotlinx.serialization.json.JsonObject
14
+ import kotlinx.serialization.json.JsonPrimitive
15
+ import kotlinx.serialization.json.buildJsonObject
16
+ import kotlinx.serialization.json.jsonObject
17
+ import kotlinx.serialization.json.jsonPrimitive
18
+
19
+ /** Project-scoped JSON-RPC client for UR's HTTP ACP endpoint. */
20
+ @Service(Service.Level.PROJECT)
21
+ class UrAcpClient(private val project: Project) {
22
+ private val baseUrl = "http://127.0.0.1:9100"
23
+ private val http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build()
24
+ private val ids = AtomicLong()
25
+ private val json = Json { ignoreUnknownKeys = true }
26
+ @Volatile private var initialized = false
27
+ @Volatile private var sessionId: String? = null
28
+
29
+ fun health(): Boolean = try {
30
+ val req = HttpRequest.newBuilder(URI.create("$baseUrl/healthz"))
31
+ .timeout(Duration.ofSeconds(5)).GET().build()
32
+ http.send(req, HttpResponse.BodyHandlers.ofString()).statusCode() == 200
33
+ } catch (_: Exception) { false }
34
+
35
+ @Synchronized
36
+ fun sendPrompt(prompt: String): String {
37
+ require(prompt.isNotBlank()) { "Prompt cannot be empty." }
38
+ ensureSession()
39
+ val result = rpc("session/prompt", buildJsonObject {
40
+ put("sessionId", JsonPrimitive(sessionId!!))
41
+ put("prompt", JsonPrimitive(prompt))
42
+ put("mode", JsonPrimitive("sync"))
43
+ }).jsonObject
44
+ val task = result["task"]?.jsonObject ?: error("ACP response did not include a task.")
45
+ val status = task["status"]?.jsonPrimitive?.content ?: "unknown"
46
+ val output = task["result"]?.jsonObject?.get("stdout")?.jsonPrimitive?.content
47
+ val failure = task["result"]?.jsonObject?.get("stderr")?.jsonPrimitive?.content
48
+ return output?.takeIf { it.isNotBlank() }
49
+ ?: failure?.takeIf { it.isNotBlank() }
50
+ ?: "UR task $status (${task["id"]?.jsonPrimitive?.content ?: "unknown id"})"
51
+ }
52
+
53
+ @Synchronized
54
+ fun closeSession() {
55
+ val active = sessionId ?: return
56
+ try { rpc("session/cancel", buildJsonObject { put("sessionId", JsonPrimitive(active)) }) }
57
+ finally { sessionId = null }
58
+ }
59
+
60
+ private fun ensureSession() {
61
+ if (!initialized) {
62
+ rpc("initialize")
63
+ initialized = true
64
+ }
65
+ if (sessionId == null) {
66
+ val params = project.basePath?.let { cwd ->
67
+ buildJsonObject { put("cwd", JsonPrimitive(cwd)) }
68
+ }
69
+ sessionId = rpc("session/new", params).jsonObject["sessionId"]?.jsonPrimitive?.content
70
+ ?: error("ACP session/new response did not include sessionId.")
71
+ }
72
+ }
73
+
74
+ private fun rpc(method: String, params: JsonObject? = null): JsonElement {
75
+ val requestId = ids.incrementAndGet()
76
+ val body = buildJsonObject {
77
+ put("jsonrpc", JsonPrimitive("2.0"))
78
+ put("id", JsonPrimitive(requestId))
79
+ put("method", JsonPrimitive(method))
80
+ if (params != null) put("params", params)
81
+ }
82
+ val request = HttpRequest.newBuilder(URI.create("$baseUrl/acp"))
83
+ .timeout(Duration.ofMinutes(31))
84
+ .header("content-type", "application/json")
85
+ .POST(HttpRequest.BodyPublishers.ofString(body.toString()))
86
+ .build()
87
+ val response = http.send(request, HttpResponse.BodyHandlers.ofString())
88
+ if (response.statusCode() !in 200..299) {
89
+ error("ACP HTTP ${response.statusCode()}: ${response.body().take(500)}")
90
+ }
91
+ val payload = json.parseToJsonElement(response.body()).jsonObject
92
+ payload["error"]?.jsonObject?.let { rpcError ->
93
+ error("ACP ${rpcError["code"]?.jsonPrimitive?.content}: ${rpcError["message"]?.jsonPrimitive?.content}")
94
+ }
95
+ return payload["result"] ?: error("ACP response did not include a result.")
96
+ }
97
+ }
@@ -0,0 +1,27 @@
1
+ package dev.urnexus
2
+
3
+ import com.intellij.openapi.project.Project
4
+ import com.intellij.openapi.wm.ToolWindow
5
+ import com.intellij.openapi.wm.ToolWindowFactory
6
+ import com.intellij.ui.components.JBScrollPane
7
+ import com.intellij.ui.content.ContentFactory
8
+ import javax.swing.JTextArea
9
+ import com.intellij.openapi.application.ApplicationManager
10
+
11
+ class UrToolWindowFactory : ToolWindowFactory {
12
+ override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) {
13
+ val client = project.getService(UrAcpClient::class.java)
14
+ val text = JTextArea()
15
+ text.isEditable = false
16
+ text.text = "Checking UR ACP server..."
17
+ val content = ContentFactory.getInstance().createContent(JBScrollPane(text), "", false)
18
+ toolWindow.contentManager.addContent(content)
19
+ ApplicationManager.getApplication().executeOnPooledThread {
20
+ val status = if (client.health())
21
+ "Connected to UR ACP server (127.0.0.1:9100)."
22
+ else
23
+ "UR ACP server not running.\nStart it with: ur acp serve --port 9100"
24
+ ApplicationManager.getApplication().invokeLater { text.text = status }
25
+ }
26
+ }
27
+ }
@@ -0,0 +1,17 @@
1
+ <idea-plugin>
2
+ <id>dev.urnexus.jetbrains</id>
3
+ <name>UR Agent</name>
4
+ <vendor url="https://github.com/Maitham16/UR">UR-Nexus</vendor>
5
+ <description>Connects JetBrains IDEs to a local UR-Nexus agent over ACP (ur acp serve).</description>
6
+ <depends>com.intellij.modules.platform</depends>
7
+ <extensions defaultExtensionNs="com.intellij">
8
+ <toolWindow id="UR Agent" anchor="right"
9
+ factoryClass="dev.urnexus.UrToolWindowFactory"/>
10
+ </extensions>
11
+ <actions>
12
+ <action id="UR.SendSelection" class="dev.urnexus.SendSelectionAction"
13
+ text="UR: Send Selection" description="Send the current selection to the UR agent">
14
+ <add-to-group group-id="ToolsMenu" anchor="last"/>
15
+ </action>
16
+ </actions>
17
+ </idea-plugin>
@@ -62,7 +62,8 @@ var fs = __toESM(require("node:fs"));
62
62
  var path = __toESM(require("node:path"));
63
63
  var vscode2 = __toESM(require("vscode"));
64
64
  function workspaceRoot() {
65
- return vscode2.workspace.workspaceFolders?.[0]?.uri.fsPath;
65
+ const activeUri = vscode2.window.activeTextEditor?.document.uri;
66
+ return (activeUri ? vscode2.workspace.getWorkspaceFolder(activeUri) : void 0)?.uri.fsPath ?? vscode2.workspace.workspaceFolders?.[0]?.uri.fsPath;
66
67
  }
67
68
  function diffsRoot(root) {
68
69
  return path.join(root, ".ur", "ide", "diffs");
@@ -71,10 +72,34 @@ function manifestPath(root) {
71
72
  return path.join(diffsRoot(root), "manifest.json");
72
73
  }
73
74
  function patchPath(root, bundle) {
74
- return path.join(diffsRoot(root), bundle.patchFile);
75
+ return artifactPath(root, bundle, "patch");
75
76
  }
76
77
  function metadataPath(root, bundle) {
77
- return path.join(diffsRoot(root), bundle.metadataFile);
78
+ return artifactPath(root, bundle, "metadata");
79
+ }
80
+ var DIFF_ID_PATTERN = /^diff-[1-9][0-9]*$/u;
81
+ function artifactPath(root, bundle, kind) {
82
+ if (!DIFF_ID_PATTERN.test(bundle.id)) throw new Error(`Invalid UR diff id: ${bundle.id}`);
83
+ const relative2 = kind === "patch" ? bundle.patchFile : bundle.metadataFile;
84
+ const expected = kind === "patch" ? `patches/${bundle.id}.patch` : `metadata/${bundle.id}.json`;
85
+ if (relative2.replaceAll("\\", "/") !== expected) {
86
+ throw new Error(`Invalid UR diff ${kind} path for ${bundle.id}`);
87
+ }
88
+ const rootPath = path.resolve(diffsRoot(root));
89
+ const target = path.resolve(rootPath, relative2);
90
+ if (!target.startsWith(`${rootPath}${path.sep}`)) throw new Error(`UR diff ${kind} path escapes the diff store`);
91
+ return target;
92
+ }
93
+ function isValidBundle(value) {
94
+ if (!value || typeof value !== "object") return false;
95
+ const bundle = value;
96
+ try {
97
+ patchPath(".", bundle);
98
+ metadataPath(".", bundle);
99
+ return true;
100
+ } catch {
101
+ return false;
102
+ }
78
103
  }
79
104
  function readJson(file, fallback) {
80
105
  try {
@@ -90,10 +115,11 @@ function writeJson(file, value) {
90
115
  }
91
116
  function loadManifest(root) {
92
117
  const manifest = readJson(manifestPath(root), { version: 1, diffs: [] });
93
- return Array.isArray(manifest.diffs) ? manifest : { version: 1, diffs: [] };
118
+ return Array.isArray(manifest.diffs) ? { version: 1, diffs: manifest.diffs.filter(isValidBundle) } : { version: 1, diffs: [] };
94
119
  }
95
120
  function loadBundleMetadata(root, bundle) {
96
- return readJson(metadataPath(root, bundle), bundle);
121
+ const metadata = readJson(metadataPath(root, bundle), bundle);
122
+ return isValidBundle(metadata) && metadata.id === bundle.id ? metadata : bundle;
97
123
  }
98
124
  function readPatch(root, bundle) {
99
125
  const file = patchPath(root, bundle);
@@ -720,9 +746,9 @@ function languageIdToFence(languageId) {
720
746
  }
721
747
  function captureEditorSnapshot() {
722
748
  const vscode18 = require("vscode");
723
- const workspaceRoot2 = vscode18.workspace.workspaceFolders?.[0]?.uri.fsPath;
724
749
  const editor = vscode18.window.activeTextEditor;
725
- if (!editor) return { workspaceRoot: workspaceRoot2 };
750
+ if (!editor) return { workspaceRoot: vscode18.workspace.workspaceFolders?.[0]?.uri.fsPath };
751
+ const workspaceRoot2 = vscode18.workspace.getWorkspaceFolder(editor.document.uri)?.uri.fsPath;
726
752
  const absolutePath = editor.document.uri.fsPath;
727
753
  const relativePath = workspaceRoot2 ? path2.relative(workspaceRoot2, absolutePath) : absolutePath;
728
754
  const activeFile = { path: relativePath, languageId: editor.document.languageId };
@@ -1294,12 +1320,16 @@ var ChatController = class {
1294
1320
  attachments = [];
1295
1321
  status = "idle";
1296
1322
  turnHandle;
1323
+ activeTurnId = 0;
1297
1324
  pendingPermissions = /* @__PURE__ */ new Map();
1298
1325
  // --- commands ---
1299
1326
  async newChat() {
1300
1327
  const root = this.requireWorkspaceRoot();
1301
1328
  if (!root) return;
1302
1329
  this.turnHandle?.cancel();
1330
+ this.activeTurnId++;
1331
+ this.turnHandle = void 0;
1332
+ this.denyAllPending("A new chat was started.");
1303
1333
  this.record = createSession(root);
1304
1334
  this.attachments = [];
1305
1335
  this.status = "idle";
@@ -1418,6 +1448,10 @@ var ChatController = class {
1418
1448
  vscode6.window.showWarningMessage(reason);
1419
1449
  return;
1420
1450
  }
1451
+ if (this.record && snapshot.workspaceRoot !== this.record.session.workspaceRoot) {
1452
+ vscode6.window.showWarningMessage("The active file belongs to a different workspace folder. Start a new chat for that folder.");
1453
+ return;
1454
+ }
1421
1455
  const attachment = kind === "file" ? { kind: "file", file: snapshot.activeFile } : { kind: "selection", selection: snapshot.selection };
1422
1456
  this.attachments.push(attachment);
1423
1457
  this.ensurePanel();
@@ -1437,7 +1471,7 @@ var ChatController = class {
1437
1471
  /** The single pathway every turn goes through — manual sends and editor
1438
1472
  * actions alike. */
1439
1473
  async dispatchTurn(promptText) {
1440
- const root = this.requireWorkspaceRoot();
1474
+ const root = this.record?.session.workspaceRoot ?? this.requireWorkspaceRoot();
1441
1475
  if (!root) return;
1442
1476
  if (this.status === "running") {
1443
1477
  vscode6.window.showWarningMessage("UR is already running a request. Cancel it first or wait for it to finish.");
@@ -1460,17 +1494,24 @@ var ChatController = class {
1460
1494
  this._onDidChangeState.fire();
1461
1495
  this.panel?.post({ type: "statusChanged", status: this.status });
1462
1496
  const resumeSessionId = this.record.session.cliSessionId;
1463
- this.turnHandle = runUrTurn(
1497
+ const turnId = ++this.activeTurnId;
1498
+ let exitedSynchronously = false;
1499
+ const handle = runUrTurn(
1464
1500
  { cwd: root, prompt: promptText, resumeSessionId },
1465
1501
  {
1466
- onMessage: (message) => this.handleStreamMessage(root, sessionId, message),
1467
- onControlRequest: (request) => this.handlePermissionRequest(request),
1468
- onExit: (result) => this.handleTurnExit(root, result)
1502
+ onMessage: (message) => this.handleStreamMessage(root, sessionId, turnId, message),
1503
+ onControlRequest: (request) => this.handlePermissionRequest(turnId, request),
1504
+ onExit: (result) => {
1505
+ exitedSynchronously = true;
1506
+ this.handleTurnExit(root, sessionId, turnId, result);
1507
+ }
1469
1508
  },
1470
1509
  { executable: resolveUrCommand({ cwd: root, config: readUrCommandConfig() }) }
1471
1510
  );
1511
+ this.turnHandle = exitedSynchronously ? void 0 : handle;
1472
1512
  }
1473
- handleStreamMessage(root, sessionId, message) {
1513
+ handleStreamMessage(root, sessionId, turnId, message) {
1514
+ if (turnId !== this.activeTurnId) return;
1474
1515
  if (message.type === "system" && message.subtype === "init" && typeof message.session_id === "string") {
1475
1516
  if (this.record && this.record.session.id === sessionId && !this.record.session.cliSessionId) {
1476
1517
  setCliSessionId(root, sessionId, message.session_id);
@@ -1508,24 +1549,28 @@ var ChatController = class {
1508
1549
  if (!this.record) return;
1509
1550
  this.appendChatMessage(root, this.record.session.id, "status", [{ type: "text", text }]);
1510
1551
  }
1511
- handlePermissionRequest(request) {
1512
- return new Promise((resolve2) => {
1552
+ handlePermissionRequest(turnId, request) {
1553
+ if (turnId !== this.activeTurnId) {
1554
+ return Promise.resolve({ behavior: "deny", message: "This chat turn is no longer active." });
1555
+ }
1556
+ return new Promise((resolve3) => {
1513
1557
  const toolName = request.request.tool_name ?? "tool";
1514
1558
  const input = request.request.input ?? {};
1515
- this.pendingPermissions.set(request.request_id, { resolve: resolve2, toolName, input });
1559
+ this.pendingPermissions.set(request.request_id, { resolve: resolve3, toolName, input, turnId });
1516
1560
  this.panel?.post({ type: "permissionRequest", requestId: request.request_id, toolName, input });
1517
1561
  });
1518
1562
  }
1519
- handleTurnExit(root, result) {
1563
+ handleTurnExit(root, sessionId, turnId, result) {
1564
+ if (turnId !== this.activeTurnId) return;
1520
1565
  this.turnHandle = void 0;
1521
1566
  this.denyAllPending("The chat turn ended before this request was answered.");
1522
1567
  if (result.canceled) {
1523
1568
  this.status = "canceled";
1524
- this.appendStatusText(root, "Canceled.");
1569
+ this.appendChatMessage(root, sessionId, "status", [{ type: "text", text: "Canceled." }]);
1525
1570
  } else if (!result.ok) {
1526
1571
  this.status = "error";
1527
1572
  const message = result.error ?? "UR failed to complete this turn.";
1528
- this.appendStatusText(root, `Error: ${message}`);
1573
+ this.appendChatMessage(root, sessionId, "status", [{ type: "text", text: `Error: ${message}` }]);
1529
1574
  this.panel?.post({ type: "errorBanner", message });
1530
1575
  } else {
1531
1576
  this.status = "idle";
@@ -1535,13 +1580,13 @@ var ChatController = class {
1535
1580
  }
1536
1581
  resolvePermission(requestId, decision) {
1537
1582
  const pending = this.pendingPermissions.get(requestId);
1538
- if (!pending) return;
1583
+ if (!pending || pending.turnId !== this.activeTurnId) return;
1539
1584
  this.pendingPermissions.delete(requestId);
1540
1585
  pending.resolve(
1541
1586
  decision === "allow" ? { behavior: "allow", updatedInput: pending.input } : { behavior: "deny", message: "User denied this tool call from the UR Chat panel." }
1542
1587
  );
1543
1588
  this.panel?.post({ type: "permissionResolved", requestId });
1544
- const root = workspaceRoot();
1589
+ const root = this.record?.session.workspaceRoot;
1545
1590
  if (root) this.appendStatusText(root, `${decision === "allow" ? "Allowed" : "Denied"} ${pending.toolName}.`);
1546
1591
  }
1547
1592
  denyAllPending(reason) {
@@ -2,7 +2,7 @@
2
2
  "name": "ur-inline-diffs",
3
3
  "displayName": "UR Inline Diffs",
4
4
  "description": "Review, apply, and reject UR inline diff bundles from .ur/ide/diffs inside VS Code.",
5
- "version": "1.44.10",
5
+ "version": "1.45.2",
6
6
  "publisher": "ur-nexus",
7
7
  "engines": {
8
8
  "vscode": "^1.92.0"