@openagents-org/agent-launcher 0.2.180 → 0.2.182

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.
@@ -29,7 +29,11 @@
29
29
  const fs = require('fs');
30
30
  const os = require('os');
31
31
  const path = require('path');
32
- const { execSync, spawn } = require('child_process');
32
+ const { execSync } = require('child_process');
33
+ // spawn() here is the WSL bridge from ../wsl: same signature as
34
+ // child_process.spawn, and a straight pass-through unless the resolved CLI
35
+ // lives on the other side of the Windows/WSL boundary.
36
+ const { spawn } = require('../wsl');
33
37
 
34
38
  const LlmDirectAdapter = require('./llm-direct');
35
39
  const { formatAttachmentsForPrompt, SESSION_DEFAULT_RE, generateSessionTitle } = require('./utils');
@@ -71,6 +71,11 @@ class LlmDirectAdapter extends BaseAdapter {
71
71
  }
72
72
  }
73
73
 
74
+ /** The model this runner actually calls with — see `_model` above. */
75
+ modelLabel() {
76
+ return String(this.workspaceModel || this._model || '').trim() || null;
77
+ }
78
+
74
79
  _buildSystemPrompt(channelName) {
75
80
  return buildOpenclawSystemPrompt({
76
81
  agentName: this.agentName,
@@ -79,6 +84,7 @@ class LlmDirectAdapter extends BaseAdapter {
79
84
  endpoint: this.endpoint,
80
85
  token: this.token,
81
86
  mode: this._mode,
87
+ model: this.modelLabel(),
82
88
  disabledModules: this.disabledModules,
83
89
  ...this.pinnedPromptOpts(channelName),
84
90
  });
@@ -50,7 +50,11 @@
50
50
  const fs = require('fs');
51
51
  const os = require('os');
52
52
  const path = require('path');
53
- const { execSync, spawn } = require('child_process');
53
+ const { execSync } = require('child_process');
54
+ // spawn() here is the WSL bridge from ../wsl: same signature as
55
+ // child_process.spawn, and a straight pass-through unless the resolved CLI
56
+ // lives on the other side of the Windows/WSL boundary.
57
+ const { spawn, resolveWslBinary } = require('../wsl');
54
58
 
55
59
  const BaseAdapter = require('./base');
56
60
  const { whichBinary, getEnhancedEnv, aiderBinDirs } = require('../paths');
@@ -178,7 +182,7 @@ class MiniSweAgentAdapter extends BaseAdapter {
178
182
  // Shared cross-platform resolver runs `which`/`where` against an ENHANCED
179
183
  // PATH (nvm/fnm/volta/homebrew + pip/pipx/uv user-install dirs) — covers the
180
184
  // GUI/daemon "installed but not on PATH" case for a `pip install`.
181
- const resolved = whichBinary('mini');
185
+ const resolved = whichBinary('mini', { allowWsl: false });
182
186
  if (resolved) return this._resolveExePreference(resolved, IS_WINDOWS);
183
187
 
184
188
  // Explicit fallback over the pip/pipx/uv user-install bin dirs plus the uv
@@ -201,7 +205,10 @@ class MiniSweAgentAdapter extends BaseAdapter {
201
205
  if (fs.existsSync(c)) return c;
202
206
  }
203
207
  }
204
- return null;
208
+ // Nothing native anywhere. Last of all, look inside WSL: a CLI the user
209
+ // installed in their distro is a real install, and the marked path it comes
210
+ // back as is what the spawn bridge in ../wsl turns into a wsl.exe run.
211
+ return resolveWslBinary('mini');
205
212
  }
206
213
 
207
214
  /**
@@ -14,7 +14,11 @@
14
14
  const fs = require('fs');
15
15
  const path = require('path');
16
16
  const os = require('os');
17
- const { spawn, execSync } = require('child_process');
17
+ const { execSync } = require('child_process');
18
+ // spawn() here is the WSL bridge from ../wsl: same signature as
19
+ // child_process.spawn, and a straight pass-through unless the resolved CLI
20
+ // lives on the other side of the Windows/WSL boundary.
21
+ const { spawn } = require('../wsl');
18
22
 
19
23
  const BaseAdapter = require('./base');
20
24
  const { formatAttachmentsForPrompt } = require('./utils');
@@ -14,7 +14,11 @@ const fs = require('fs');
14
14
  const path = require('path');
15
15
  const os = require('os');
16
16
  const crypto = require('crypto');
17
- const { execSync, spawn } = require('child_process');
17
+ const { execSync } = require('child_process');
18
+ // spawn() here is the WSL bridge from ../wsl: same signature as
19
+ // child_process.spawn, and a straight pass-through unless the resolved CLI
20
+ // lives on the other side of the Windows/WSL boundary.
21
+ const { spawn } = require('../wsl');
18
22
 
19
23
  const BaseAdapter = require('./base');
20
24
  const { formatAttachmentsForPrompt, redactSecrets } = require('./utils');
@@ -250,6 +254,7 @@ class OpenCodeAdapter extends BaseAdapter {
250
254
  endpoint: this.endpoint,
251
255
  token: this.token,
252
256
  mode: this._mode,
257
+ model: this.modelLabel(),
253
258
  disabledModules: this.disabledModules,
254
259
  ...this.pinnedPromptOpts(channelName),
255
260
  });
@@ -42,7 +42,11 @@ const net = require('net');
42
42
  const os = require('os');
43
43
  const path = require('path');
44
44
  const crypto = require('crypto');
45
- const { spawn, execFileSync } = require('child_process');
45
+ const { execFileSync } = require('child_process');
46
+ // spawn() here is the WSL bridge from ../wsl: same signature as
47
+ // child_process.spawn, and a straight pass-through unless the resolved CLI
48
+ // lives on the other side of the Windows/WSL boundary.
49
+ const { spawn } = require('../wsl');
46
50
 
47
51
  const BaseAdapter = require('./base');
48
52
  const { formatAttachmentsForPrompt, SESSION_DEFAULT_RE, generateSessionTitle } = require('./utils');
@@ -203,6 +207,15 @@ class OpenWorkerAdapter extends BaseAdapter {
203
207
  return String(this.agentEnv.OPENWORKER_API_KEY || '').trim();
204
208
  }
205
209
 
210
+ /**
211
+ * What to tell the agent it is running on. `_model()` is the id we hand the
212
+ * server, provider prefix and all — the honest answer to "what model are
213
+ * you?", which OpenWorker's own weights cannot give.
214
+ */
215
+ modelLabel() {
216
+ return this._model() || null;
217
+ }
218
+
206
219
  /** The model for a turn: the workspace's selection wins over the saved env. */
207
220
  _model() {
208
221
  const raw = String(this.workspaceModel || this.agentEnv.OPENWORKER_MODEL || '').trim();
@@ -587,6 +600,7 @@ class OpenWorkerAdapter extends BaseAdapter {
587
600
  endpoint: this.endpoint,
588
601
  token: this.token,
589
602
  mode: this._mode,
603
+ model: this.modelLabel(),
590
604
  disabledModules: this.disabledModules,
591
605
  browserEnabled,
592
606
  });
@@ -34,7 +34,11 @@ const crypto = require('crypto');
34
34
  const fs = require('fs');
35
35
  const os = require('os');
36
36
  const path = require('path');
37
- const { execFileSync, execSync, spawn } = require('child_process');
37
+ const { execFileSync, execSync } = require('child_process');
38
+ // spawn() here is the WSL bridge from ../wsl: same signature as
39
+ // child_process.spawn, and a straight pass-through unless the resolved CLI
40
+ // lives on the other side of the Windows/WSL boundary.
41
+ const { spawn } = require('../wsl');
38
42
 
39
43
  const BaseAdapter = require('./base');
40
44
  const { formatAttachmentsForPrompt, SESSION_DEFAULT_RE, generateSessionTitle } = require('./utils');
@@ -1391,6 +1395,7 @@ class PiAdapter extends BaseAdapter {
1391
1395
  endpoint: this.endpoint,
1392
1396
  token: this.token,
1393
1397
  mode: this._mode,
1398
+ model: this.modelLabel(),
1394
1399
  disabledModules: this.disabledModules,
1395
1400
  browserEnabled,
1396
1401
  });
@@ -121,7 +121,7 @@ function workspaceSkillName(agentName) {
121
121
  * - `'skills'` → points at the agent's workspace skill (Bash + curl),
122
122
  * since no MCP server is spawned and that tool would not exist.
123
123
  */
124
- function buildWorkspaceIdentity(agentName, workspaceId, channelName, mode = 'execute', toolMode = 'mcp') {
124
+ function buildWorkspaceIdentity(agentName, workspaceId, channelName, mode = 'execute', toolMode = 'mcp', model = null) {
125
125
  const priorContext = toolMode === 'skills'
126
126
  ? (
127
127
  `When you need prior context, use the ${workspaceSkillName(agentName)} skill to ` +
@@ -135,6 +135,15 @@ function buildWorkspaceIdentity(agentName, workspaceId, channelName, mode = 'exe
135
135
  'channel argument the tool falls back to a default channel that may ' +
136
136
  'be different from where you are.\n'
137
137
  );
138
+ // The model is the one fact about itself an agent cannot look up. Asked
139
+ // "what model are you?", a CLI-driven agent answers from whatever its
140
+ // weights remember about their own training — which is how an agent
141
+ // configured with deepseek came to tell a user it was Claude. The launcher
142
+ // knows the answer exactly (it is what was passed to the CLI), so state it
143
+ // rather than leaving the agent to guess.
144
+ const modelLine = String(model || '').trim()
145
+ ? `- Model: ${String(model).trim()} (this is the model you are running on — do not guess otherwise)\n`
146
+ : '';
138
147
  return (
139
148
  `You are agent '${agentName}' connected to an OpenAgents workspace.\n` +
140
149
  'Your text responses are automatically posted to the workspace chat ' +
@@ -142,7 +151,9 @@ function buildWorkspaceIdentity(agentName, workspaceId, channelName, mode = 'exe
142
151
  '## Workspace Context\n' +
143
152
  `- Workspace ID: ${workspaceId}\n` +
144
153
  `- Channel: ${channelName} (this is the channel you are currently speaking in)\n` +
145
- `- Mode: ${mode}\n\n` +
154
+ `- Mode: ${mode}\n` +
155
+ modelLine +
156
+ '\n' +
146
157
  priorContext
147
158
  );
148
159
  }
@@ -931,10 +942,10 @@ function buildPinnedSections({ toolMode = 'mcp', channelName, mode = 'execute',
931
942
  * writeAccess? }. Both default to off — other adapters that reuse this
932
943
  * builder (e.g. Gemini) are unaffected unless they pass them.
933
944
  */
934
- function buildClaudeSystemPrompt({ agentName, workspaceId, channelName, mode = 'execute', browserEnabled = false, toolMode = 'mcp', decisionLog = null, glossary = null }) {
945
+ function buildClaudeSystemPrompt({ agentName, workspaceId, channelName, mode = 'execute', browserEnabled = false, toolMode = 'mcp', decisionLog = null, glossary = null, model = null }) {
935
946
  const skillName = workspaceSkillName(agentName);
936
947
  const parts = [];
937
- parts.push(buildWorkspaceIdentity(agentName, workspaceId, channelName, mode, toolMode));
948
+ parts.push(buildWorkspaceIdentity(agentName, workspaceId, channelName, mode, toolMode, model));
938
949
  parts.push(toolMode === 'skills' ? buildClaudeSkillsToolBlock(skillName) : buildClaudeMcpToolBlock());
939
950
  parts.push(buildBrowserDirective(browserEnabled));
940
951
  parts.push(buildCollaborationPrompt(toolMode, skillName));
@@ -960,9 +971,9 @@ function buildClaudeSystemPrompt({ agentName, workspaceId, channelName, mode = '
960
971
  * buildClaudeSystemPrompt (with skills-mode tool phrasing, since these
961
972
  * agents reach the knowledge API through curl commands).
962
973
  */
963
- function buildOpenclawSystemPrompt({ agentName, workspaceId, channelName, endpoint, token, mode = 'execute', disabledModules, browserEnabled = false, decisionLog = null, glossary = null }) {
974
+ function buildOpenclawSystemPrompt({ agentName, workspaceId, channelName, endpoint, token, mode = 'execute', disabledModules, browserEnabled = false, decisionLog = null, glossary = null, model = null }) {
964
975
  const parts = [];
965
- parts.push(buildWorkspaceIdentity(agentName, workspaceId, channelName, mode, 'skills'));
976
+ parts.push(buildWorkspaceIdentity(agentName, workspaceId, channelName, mode, 'skills', model));
966
977
  parts.push(buildBrowserDirective(browserEnabled));
967
978
  parts.push(buildCollaborationPrompt('skills', workspaceSkillName(agentName)));
968
979
  parts.push(buildModePrompt(mode));
@@ -1009,8 +1020,8 @@ function buildOpenclawSkillMd({ endpoint, workspaceId, token, agentName, channel
1009
1020
  * `decisionLog` / `glossary` opt in to knowledge pinning exactly as in
1010
1021
  * buildOpenclawSystemPrompt.
1011
1022
  */
1012
- function buildOpenCodeSystemPrompt({ agentName, workspaceId, channelName, endpoint, token, mode = 'execute', disabledModules, browserEnabled = false, decisionLog = null, glossary = null, isWindows = process.platform === 'win32' }) {
1013
- const identity = buildWorkspaceIdentity(agentName, workspaceId, channelName, mode, 'skills');
1023
+ function buildOpenCodeSystemPrompt({ agentName, workspaceId, channelName, endpoint, token, mode = 'execute', disabledModules, browserEnabled = false, decisionLog = null, glossary = null, isWindows = process.platform === 'win32', model = null }) {
1024
+ const identity = buildWorkspaceIdentity(agentName, workspaceId, channelName, mode, 'skills', model);
1014
1025
  const directive = buildBrowserDirective(browserEnabled);
1015
1026
  const collab = buildCollaborationPrompt('skills', workspaceSkillName(agentName));
1016
1027
  const modePrompt = buildModePrompt(mode) + buildWindowsShellHint(isWindows);
@@ -1031,8 +1042,8 @@ function buildOpenCodeSystemPrompt({ agentName, workspaceId, channelName, endpoi
1031
1042
  * NOTE: this is APPENDED, never passed via `--system-prompt` — that flag would
1032
1043
  * REPLACE Pi's own coding-assistant prompt and strip its tool instructions.
1033
1044
  */
1034
- function buildPiSystemPrompt({ agentName, workspaceId, channelName, endpoint, token, mode = 'execute', disabledModules, browserEnabled = false }) {
1035
- const identity = buildWorkspaceIdentity(agentName, workspaceId, channelName, mode, 'skills');
1045
+ function buildPiSystemPrompt({ agentName, workspaceId, channelName, endpoint, token, mode = 'execute', disabledModules, browserEnabled = false, model = null }) {
1046
+ const identity = buildWorkspaceIdentity(agentName, workspaceId, channelName, mode, 'skills', model);
1036
1047
  const directive = buildBrowserDirective(browserEnabled);
1037
1048
  const collab = buildCollaborationPrompt('skills', workspaceSkillName(agentName));
1038
1049
  const modePrompt = buildModePrompt(mode);
@@ -1060,8 +1071,8 @@ function buildPiSystemPrompt({ agentName, workspaceId, channelName, endpoint, to
1060
1071
  * conversation, so it is sent once and still in context on every later turn
1061
1072
  * — which is why it must read as a briefing, not as a system directive.
1062
1073
  */
1063
- function buildOpenWorkerSystemPrompt({ agentName, workspaceId, channelName, endpoint, token, mode = 'execute', disabledModules, browserEnabled = false }) {
1064
- const identity = buildWorkspaceIdentity(agentName, workspaceId, channelName, mode, 'skills');
1074
+ function buildOpenWorkerSystemPrompt({ agentName, workspaceId, channelName, endpoint, token, mode = 'execute', disabledModules, browserEnabled = false, model = null }) {
1075
+ const identity = buildWorkspaceIdentity(agentName, workspaceId, channelName, mode, 'skills', model);
1065
1076
  const directive = buildBrowserDirective(browserEnabled);
1066
1077
  const collab = buildCollaborationPrompt('skills', workspaceSkillName(agentName));
1067
1078
  const modePrompt = buildModePrompt(mode);
package/src/daemon.js CHANGED
@@ -2,7 +2,11 @@
2
2
 
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
- const { spawn, execSync, execFileSync } = require('child_process');
5
+ const { execSync, execFileSync } = require('child_process');
6
+ // spawn() here is the WSL bridge from ./wsl: same signature as
7
+ // child_process.spawn, and a straight pass-through unless the resolved CLI
8
+ // lives on the other side of the Windows/WSL boundary.
9
+ const { spawn } = require('./wsl');
6
10
  const os = require('os');
7
11
  const { WorkspaceClient } = require('./workspace-client');
8
12
  const { getEnhancedEnv, whichBinary, IS_WINDOWS, defaultAgentWorkdir } = require('./paths');
@@ -43,6 +47,7 @@ class Daemon {
43
47
  this._statusInterval = null;
44
48
  this._cmdInterval = null;
45
49
  this._nodeHeartbeatInterval = null; // device-level heartbeat (connect-a-node)
50
+ this._displayNameInterval = null; // pulls workspace-side renames back
46
51
  this._nodeClients = new Map(); // workspace_id -> WorkspaceClient, one per pairing
47
52
  this._runningCommands = new Set(); // node-command ids currently executing
48
53
  this._runtimes = []; // detected agent runtimes for the node view
@@ -225,6 +230,50 @@ class Daemon {
225
230
  }
226
231
  }
227
232
 
233
+ /**
234
+ * Reflect workspace-side renames back onto this device.
235
+ *
236
+ * The workspace is treated as authoritative for the label, which needs no
237
+ * timestamps to be correct: a rename started on the device writes to the
238
+ * workspace FIRST and only then to local config (see
239
+ * AgentConnector.setAgentDisplayName), so by the time this reads, the
240
+ * workspace already holds whatever the device just set. Anything different
241
+ * from local therefore came from the workspace and should win.
242
+ *
243
+ * Never throws — a workspace that is unreachable simply leaves the labels
244
+ * as they are until the next pass.
245
+ */
246
+ async _syncDisplayNames() {
247
+ let pairings = [];
248
+ try {
249
+ pairings = require('./node-config').listPairings();
250
+ } catch { return; }
251
+
252
+ for (const n of pairings) {
253
+ if (!n.workspace_id || !n.token) continue;
254
+ let members = [];
255
+ try {
256
+ members = await this._nodeClientFor(n).getAgents(n.workspace_id, n.token);
257
+ } catch { continue; }
258
+
259
+ const byName = new Map(members.map((m) => [m.agentName, m]));
260
+ for (const a of this.config.getAgents()) {
261
+ // Scoped to this pairing's workspace, like _buildRoster: an agent in
262
+ // another workspace must never be relabelled from this one.
263
+ if (!this._agentOnNodeWorkspace(a, n)) continue;
264
+ const remote = byName.get(a.name);
265
+ if (!remote) continue;
266
+ const local = a.display_name || null;
267
+ const wanted = remote.displayName || null;
268
+ if (local === wanted) continue;
269
+ try {
270
+ this.config.updateAgent(a.name, { display_name: wanted || undefined });
271
+ this._log(`display name for ${a.name} updated from workspace`);
272
+ } catch {}
273
+ }
274
+ }
275
+ }
276
+
228
277
  /**
229
278
  * True if agent `a` is bound to the node's currently-connected workspace.
230
279
  * A missing network never matches (guards against undefined === undefined
@@ -608,6 +657,15 @@ async _runNodeCommand(n, cmd) {
608
657
  15000,
609
658
  );
610
659
 
660
+ // Pull display-name renames made in the workspace back onto the device, so
661
+ // the two sides agree whichever one the user typed into. Separate from the
662
+ // 10s heartbeat on purpose: a label changes rarely and this is an extra
663
+ // request per paired workspace, so it runs a sixth as often.
664
+ this._displayNameInterval = setInterval(
665
+ () => this._syncDisplayNames(),
666
+ 60000,
667
+ );
668
+
611
669
  // Detect installed/logged-in agent runtimes for the Add-agent gallery. Runs
612
670
  // in a child process (off the event loop), refreshed periodically.
613
671
  this._refreshRuntimes();
@@ -658,6 +716,7 @@ async _runNodeCommand(n, cmd) {
658
716
  if (this._nodeHeartbeatInterval) clearInterval(this._nodeHeartbeatInterval);
659
717
  if (this._credReconcileInterval) clearInterval(this._credReconcileInterval);
660
718
  if (this._runtimesInterval) clearInterval(this._runtimesInterval);
719
+ if (this._displayNameInterval) clearInterval(this._displayNameInterval);
661
720
  if (this._probeStartupTimer) clearTimeout(this._probeStartupTimer);
662
721
  if (this._probeInterval) clearInterval(this._probeInterval);
663
722
  if (this._configWatcher) { try { this._configWatcher.close(); } catch {} }
package/src/index.js CHANGED
@@ -84,6 +84,10 @@ class AgentConnector {
84
84
  const network = networks.find((n) => n.slug === a.network || n.id === a.network);
85
85
  return {
86
86
  name: a.name,
87
+ // The label to show. `name` stays the identity — callers that key on
88
+ // it (sessions, working dirs, workspace membership) must keep using
89
+ // `name`, never this.
90
+ displayName: a.display_name || null,
87
91
  type,
88
92
  role: a.role || 'worker',
89
93
  network: a.network || null,
@@ -100,6 +104,95 @@ class AgentConnector {
100
104
  return { success: true };
101
105
  }
102
106
 
107
+ /**
108
+ * The workspace an agent is bound to, as a ready-to-use client — or null
109
+ * when it is local-only or its network is no longer configured.
110
+ *
111
+ * Each network carries its own endpoint (a self-hosted workspace is not the
112
+ * official one), so the client is built per call rather than reusing
113
+ * `this.workspace`, the same way removeWorkspace does.
114
+ */
115
+ _workspaceClientFor(agentName) {
116
+ const agent = this.config.getAgent(agentName);
117
+ if (!agent || !agent.network) return null;
118
+ const network = this.config
119
+ .getNetworks()
120
+ .find((n) => n.slug === agent.network || n.id === agent.network);
121
+ if (!network || !network.id) return null;
122
+ const endpoint = network.endpoint || (this.workspace && this.workspace.endpoint);
123
+ const { WorkspaceClient } = require('./workspace-client');
124
+ return { client: new WorkspaceClient(endpoint), network };
125
+ }
126
+
127
+ /**
128
+ * Set (or clear, with an empty value) an agent's display label.
129
+ *
130
+ * The agent's `name` is its identity — it keys daemon.yaml, the working
131
+ * directory under ~/.openagents/agents, its sessions and its workspace
132
+ * membership — so renaming NEVER touches it. What changes is the label the
133
+ * launcher and the workspace show, which is precisely what the workspace
134
+ * already models as `display_name` next to `agent_name`.
135
+ *
136
+ * When the agent is in a workspace the new label is pushed there too, so the
137
+ * two sides agree. A workspace that rejects it (a name another member
138
+ * already answers to) fails the whole call rather than leaving the label
139
+ * changed on one side only.
140
+ */
141
+ async setAgentDisplayName(name, displayName) {
142
+ const agent = this.config.getAgent(name);
143
+ if (!agent) throw new Error(`Agent '${name}' not found`);
144
+ const label = (displayName || '').trim();
145
+
146
+ const bound = this._workspaceClientFor(name);
147
+ if (bound) {
148
+ await bound.client.setMemberDisplayName(
149
+ bound.network.id,
150
+ bound.network.token || '',
151
+ name,
152
+ label,
153
+ );
154
+ }
155
+ // Local write comes second: if the workspace refused the label, nothing
156
+ // here has changed and the two sides are still in agreement.
157
+ this.config.updateAgent(name, { display_name: label || undefined });
158
+ return { success: true, displayName: label || null };
159
+ }
160
+
161
+ /**
162
+ * Drop an agent's member row from the workspace it joined.
163
+ *
164
+ * Deliberately SEPARATE from removeAgent rather than a flag on it. Removing
165
+ * an agent is a synchronous local edit that `agn remove` and the TUI call
166
+ * without awaiting, inside a try/catch — making it async would turn a thrown
167
+ * error into an unhandled rejection those callers can no longer see. So the
168
+ * network half lives here, and a caller that wants both awaits this first:
169
+ * failing to reach the workspace then leaves the agent intact locally, which
170
+ * is the recoverable order.
171
+ *
172
+ * A local-only agent is a no-op success — there is nothing over there.
173
+ */
174
+ async removeAgentFromWorkspace(name) {
175
+ const bound = this._workspaceClientFor(name);
176
+ if (!bound) return { success: true, skipped: true };
177
+ try {
178
+ await bound.client.removeMember(
179
+ bound.network.id,
180
+ bound.network.token || '',
181
+ name,
182
+ );
183
+ } catch (e) {
184
+ // 404 is the goal state, reached by someone else: the member row is
185
+ // already gone, or the whole workspace is. Treating it as a failure
186
+ // would strand the agent on this device — removal happens local-last, so
187
+ // an error here means it cannot be removed at all. Anything else (a
188
+ // network blip, an auth failure) is real and must stop the removal so
189
+ // the user can retry rather than lose track of a live membership.
190
+ if (!e || e.status !== 404) throw e;
191
+ return { success: true, alreadyGone: true };
192
+ }
193
+ return { success: true };
194
+ }
195
+
103
196
  removeAgent(name) {
104
197
  this.config.removeAgent(name);
105
198
  return { success: true };
@@ -193,11 +286,38 @@ class AgentConnector {
193
286
  return { success: true };
194
287
  }
195
288
 
289
+ /**
290
+ * Local half of leaving a workspace: forget the binding, keep the agent.
291
+ *
292
+ * Synchronous, and deliberately still local-only — `agn disconnect` and the
293
+ * TUI call it without awaiting, inside a try/catch, so an async version
294
+ * would turn a throw into an unhandled rejection they can no longer see.
295
+ * Callers that want the membership dropped too use leaveWorkspace below.
296
+ */
196
297
  disconnectWorkspace(agentName) {
197
298
  this.config.setAgentNetwork(agentName, null);
198
299
  return { success: true };
199
300
  }
200
301
 
302
+ /**
303
+ * Leave a workspace properly: drop the member row over there, and unbind
304
+ * here — the agent itself stays, with its config, credentials and working
305
+ * directory, ready to join another workspace.
306
+ *
307
+ * Leaving only the local half is the bug this replaces. The device stopped
308
+ * showing the agent as connected while the workspace went on listing it as a
309
+ * member, and nothing in the launcher could reach that row any more. The
310
+ * workspace-level "remove a workspace" action learned this same lesson
311
+ * already (see removeWorkspace) — this is the agent-level equivalent.
312
+ *
313
+ * Workspace first, local second: a failure there leaves the agent bound and
314
+ * retryable rather than orphaning a membership.
315
+ */
316
+ async leaveWorkspace(agentName) {
317
+ await this.removeAgentFromWorkspace(agentName);
318
+ return this.disconnectWorkspace(agentName);
319
+ }
320
+
201
321
  async removeWorkspace(slug) {
202
322
  const networks = this.config.getNetworks();
203
323
  const network = networks.find(n => n.slug === slug || n.id === slug);
package/src/installer.js CHANGED
@@ -5,6 +5,7 @@ const os = require('os');
5
5
  const path = require('path');
6
6
  const { execSync, exec } = require('child_process');
7
7
  const { whichBinary, getEnhancedEnv, getRuntimePrefix, clearBinaryLookupCache, aiderBinDirs, resolveBinaryInKnownDirs } = require('./paths');
8
+ const { isWslBinary, bridgedCommandString, wslHomeUnc } = require('./wsl');
8
9
  const { EnvManager } = require('./env');
9
10
  const { nodeDistUrls, installRegistry } = require('./mirrors');
10
11
  const { readinessReason, REASON } = require('./adapters/health-status');
@@ -173,6 +174,13 @@ class Installer {
173
174
  return { installed: false, managed: false, location: null };
174
175
  }
175
176
 
177
+ // Installed inside a WSL distro. Never 'managed': the launcher did not put
178
+ // it there and cannot uninstall it, and the isolated-runtime bookkeeping
179
+ // below is all about Windows paths that an in-distro binary can never match.
180
+ if (isWslBinary(binaryPath)) {
181
+ return { installed: true, managed: false, location: 'wsl' };
182
+ }
183
+
176
184
  // Verify it's not a stale shim pointing to a missing package
177
185
  const openagentsDir = path.join(os.homedir(), '.openagents');
178
186
  if (binaryPath.startsWith(openagentsDir)) {
@@ -580,6 +588,11 @@ class Installer {
580
588
  * shebang), so it's applied on every platform for consistency.
581
589
  */
582
590
  _versionProbeCommand(binary) {
591
+ // A binary inside WSL has to be asked through wsl.exe; running the Linux
592
+ // path as a Windows command yields "not recognized" and the marketplace
593
+ // then shows an installed agent with no version at all.
594
+ const bridged = bridgedCommandString(binary, ['--version']);
595
+ if (bridged) return bridged;
583
596
  if (/\.(mjs|cjs|js)$/i.test(binary)) {
584
597
  return `"${this._nodeBinary()}" "${binary}" --version`;
585
598
  }
@@ -744,14 +757,14 @@ class Installer {
744
757
  // authoritative; the legacy parser treats an empty {} as ready.
745
758
  const credsReady = checkReady.creds_json_has_entries
746
759
  ? false
747
- : this._checkCredsReady(checkReady);
760
+ : this._checkCredsReady(checkReady, agentType);
748
761
  // Content-free credential probes (opt-in via check_ready). Detect agents
749
762
  // whose sign-in is a local OAuth/credential FILE (e.g. Gemini's
750
763
  // ~/.gemini/oauth_creds.json) or a service-account file path, WITHOUT
751
764
  // parsing or logging the token. Additive: agents that don't declare
752
765
  // creds_no_parse / creds_path_env get identical behavior to before.
753
- const credsFile = this._evaluateCredsFile(checkReady);
754
- const credsPathReady = this._checkCredsPathEnv(checkReady, savedEnv);
766
+ const credsFile = this._evaluateCredsFile(checkReady, agentType);
767
+ const credsPathReady = this._checkCredsPathEnv(checkReady, savedEnv, agentType);
755
768
 
756
769
  let cliReady = false;
757
770
  if (checkReady.status_command && binary) {
@@ -839,10 +852,10 @@ class Installer {
839
852
  return keys.some((key) => !!(source && source[key]));
840
853
  }
841
854
 
842
- _checkCredsReady(checkReady) {
855
+ _checkCredsReady(checkReady, agentType) {
843
856
  if (checkReady.creds_file) {
844
857
  try {
845
- const credsPath = checkReady.creds_file.replace('~', os.homedir());
858
+ const credsPath = this._expandHome(checkReady.creds_file, agentType);
846
859
  if (fs.existsSync(credsPath)) {
847
860
  const stat = fs.statSync(credsPath);
848
861
  if (stat.isDirectory()) return fs.readdirSync(credsPath).length > 0;
@@ -862,14 +875,38 @@ class Installer {
862
875
  return false;
863
876
  }
864
877
 
865
- /** Expand a leading "~" to the running user's home directory. */
866
- _expandHome(p) {
878
+ /**
879
+ * Expand a leading "~" to the home directory the AGENT writes its sign-in to.
880
+ *
881
+ * Normally the running user's. For an agent that resolved inside WSL it is
882
+ * the distro's $HOME, reached over the \\wsl.localhost mount — `claude login`
883
+ * run in the distro leaves ~/.claude there, and expanding against the Windows
884
+ * profile instead reports a signed-in agent as "Not logged in".
885
+ */
886
+ _expandHome(p, agentType) {
867
887
  if (typeof p !== 'string' || !p) return p;
868
- if (p === '~') return os.homedir();
869
- if (p.startsWith('~/') || p.startsWith('~\\')) return path.join(os.homedir(), p.slice(2));
888
+ const home = this._homeForAgent(agentType);
889
+ if (p === '~') return home;
890
+ if (p.startsWith('~/') || p.startsWith('~\\')) return path.join(home, p.slice(2));
870
891
  return p;
871
892
  }
872
893
 
894
+ /**
895
+ * Where this agent's dotfiles live, as a path this process can read. Falls
896
+ * back to the running user's home for every native install — and for a WSL
897
+ * one whose distro home could not be resolved, which is no worse than the
898
+ * behaviour before the WSL tier existed.
899
+ */
900
+ _homeForAgent(agentType) {
901
+ if (!agentType) return os.homedir();
902
+ try {
903
+ if (isWslBinary(this._whichBinary(agentType))) {
904
+ return wslHomeUnc() || os.homedir();
905
+ }
906
+ } catch { /* resolution is best-effort; the host home is the safe answer */ }
907
+ return os.homedir();
908
+ }
909
+
873
910
  /**
874
911
  * Existence/readability of a credential file. Returns 'present' |
875
912
  * 'unreadable' | 'absent'. A non-empty readable file (or a non-empty
@@ -881,9 +918,9 @@ class Installer {
881
918
  * against the running user's home (the daemon/launcher user), not the
882
919
  * renderer/browser user.
883
920
  */
884
- _credsFileState(checkReady) {
921
+ _credsFileState(checkReady, agentType) {
885
922
  if (!checkReady || !checkReady.creds_file) return 'absent';
886
- const p = this._expandHome(checkReady.creds_file);
923
+ const p = this._expandHome(checkReady.creds_file, agentType);
887
924
  let exists = false;
888
925
  try { exists = fs.existsSync(p); } catch { return 'unreadable'; }
889
926
  if (!exists) return 'absent';
@@ -922,11 +959,11 @@ class Installer {
922
959
  * parse + match `creds_key` via _checkCredsReady) keep their exact behavior.
923
960
  * Returns { ready, unreadable }.
924
961
  */
925
- _evaluateCredsFile(checkReady) {
962
+ _evaluateCredsFile(checkReady, agentType) {
926
963
  if (!checkReady || !checkReady.creds_file || !checkReady.creds_no_parse) {
927
964
  return { ready: false, unreadable: false };
928
965
  }
929
- const state = this._credsFileState(checkReady);
966
+ const state = this._credsFileState(checkReady, agentType);
930
967
  return { ready: state === 'present', unreadable: state === 'unreadable' };
931
968
  }
932
969
 
@@ -936,14 +973,14 @@ class Installer {
936
973
  * exists and is a readable file — a non-empty value alone is NOT enough.
937
974
  * Checks both the process env and the agent's saved env.
938
975
  */
939
- _checkCredsPathEnv(checkReady, savedEnv) {
976
+ _checkCredsPathEnv(checkReady, savedEnv, agentType) {
940
977
  const names = checkReady && checkReady.creds_path_env;
941
978
  if (!Array.isArray(names) || !names.length) return false;
942
979
  for (const name of names) {
943
980
  const raw = ((process.env[name] || (savedEnv && savedEnv[name]) || '') + '').trim();
944
981
  if (!raw) continue;
945
982
  try {
946
- const p = this._expandHome(raw);
983
+ const p = this._expandHome(raw, agentType);
947
984
  if (!fs.existsSync(p)) continue;
948
985
  fs.accessSync(p, fs.constants.R_OK);
949
986
  if (fs.statSync(p).isFile()) return true;
@@ -1568,8 +1605,12 @@ class Installer {
1568
1605
  const managed = this._resolveManagedBinary(agentType, entry, binary, aliases);
1569
1606
  if (managed) return managed;
1570
1607
 
1608
+ // allowWsl:false — whichBinary would otherwise end its own search inside
1609
+ // the distro, and this method still has two NATIVE tiers to run below. A
1610
+ // Windows copy must win over a WSL one; resolveBinaryInKnownDirs() reaches
1611
+ // the distro at the very end, once everything on this side has missed.
1571
1612
  for (const candidate of [binary, ...aliases]) {
1572
- const found = whichBinary(candidate);
1613
+ const found = whichBinary(candidate, { allowWsl: false });
1573
1614
  if (found) return found;
1574
1615
  }
1575
1616
  // Fallback: resolve the installed package's OWN bin from its package.json