@phenx-inc/ctlsurf 0.11.0 → 0.12.1

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 (38) hide show
  1. package/out/headless/index.mjs +115 -5
  2. package/out/headless/index.mjs.map +3 -3
  3. package/out/main/index.js +316 -8
  4. package/out/preload/index.js +9 -0
  5. package/out/renderer/assets/{cssMode-Bmzy9hKX.js → cssMode-D7r66wvO.js} +3 -3
  6. package/out/renderer/assets/{freemarker2-CDAsExTa.js → freemarker2-jSWJIjPG.js} +1 -1
  7. package/out/renderer/assets/{handlebars-CvpBYG1L.js → handlebars-BnFyz5RH.js} +1 -1
  8. package/out/renderer/assets/{html-CNhEQqr8.js → html-Bl5UFavp.js} +1 -1
  9. package/out/renderer/assets/{htmlMode-DD7YPgga.js → htmlMode-D4VByBT6.js} +3 -3
  10. package/out/renderer/assets/{index-CGHeDwZa.css → index-Cc_TqOCL.css} +318 -0
  11. package/out/renderer/assets/{index-BDlCPzCG.js → index-DxhW1emk.js} +558 -85
  12. package/out/renderer/assets/{javascript-CsGLsswK.js → javascript-CMN1OmwG.js} +2 -2
  13. package/out/renderer/assets/{jsonMode-CTuGgRvC.js → jsonMode-CJ5AYSPV.js} +3 -3
  14. package/out/renderer/assets/{liquid-BrrN4uul.js → liquid-BGMIDTEm.js} +1 -1
  15. package/out/renderer/assets/{lspLanguageFeatures-Bhdn2Eqy.js → lspLanguageFeatures-DGifVYB5.js} +1 -1
  16. package/out/renderer/assets/{mdx-DJo-gax4.js → mdx-B2Z7R10Q.js} +1 -1
  17. package/out/renderer/assets/{python-DYHeg5b2.js → python-LMDMSSdn.js} +1 -1
  18. package/out/renderer/assets/{razor-Cqcks14K.js → razor-BZSrziPu.js} +1 -1
  19. package/out/renderer/assets/{tsMode-YTLgCayh.js → tsMode-DF9m6_3n.js} +1 -1
  20. package/out/renderer/assets/{typescript-oxBn_Vcw.js → typescript-rZeCMf0a.js} +1 -1
  21. package/out/renderer/assets/{xml-BEqGMCTc.js → xml-fNSZ-7so.js} +1 -1
  22. package/out/renderer/assets/{yaml-BGSrk5HC.js → yaml-1wxIuvzh.js} +1 -1
  23. package/out/renderer/index.html +2 -2
  24. package/package.json +1 -1
  25. package/src/main/agents.ts +9 -1
  26. package/src/main/ctlsurfApi.ts +7 -0
  27. package/src/main/index.ts +62 -4
  28. package/src/main/orchestrator.ts +112 -4
  29. package/src/main/ssh.ts +348 -0
  30. package/src/preload/index.ts +26 -0
  31. package/src/renderer/App.tsx +25 -1
  32. package/src/renderer/components/AgentPicker.tsx +18 -0
  33. package/src/renderer/components/ProjectPicker.tsx +121 -0
  34. package/src/renderer/components/RemoteConnect.tsx +315 -0
  35. package/src/renderer/components/SpeakControls.tsx +8 -2
  36. package/src/renderer/components/TerminalPanel.tsx +134 -23
  37. package/src/renderer/components/TicketPanel.tsx +67 -26
  38. package/src/renderer/styles.css +318 -0
package/out/main/index.js CHANGED
@@ -5,6 +5,7 @@ const fs = require("fs");
5
5
  const https = require("https");
6
6
  const os = require("os");
7
7
  const module$1 = require("module");
8
+ const child_process = require("child_process");
8
9
  const require$$1 = require("crypto");
9
10
  const require$$0$3 = require("events");
10
11
  const require$$2 = require("http");
@@ -109,8 +110,11 @@ function getAvailableAgents() {
109
110
  function getDefaultAgent() {
110
111
  return getAvailableAgents()[0];
111
112
  }
113
+ function isRemoteAgent(agent) {
114
+ return agent.id.startsWith("remote-");
115
+ }
112
116
  function isCodingAgent(agent) {
113
- return agent.id !== "shell";
117
+ return agent.id !== "shell" && !isRemoteAgent(agent);
114
118
  }
115
119
  function getSettingsDir(useElectron) {
116
120
  {
@@ -167,6 +171,180 @@ class PtyManager {
167
171
  this.exitCallbacks.push(cb);
168
172
  }
169
173
  }
174
+ const TMUX_SESSION_PREFIX = "ctlsurf_";
175
+ function listSshHosts(configPath) {
176
+ const file = path.join(os.homedir(), ".ssh", "config");
177
+ let raw;
178
+ try {
179
+ raw = fs.readFileSync(file, "utf-8");
180
+ } catch {
181
+ return [];
182
+ }
183
+ const hosts = [];
184
+ let current = [];
185
+ const flush = () => {
186
+ for (const h of current) hosts.push(h);
187
+ current = [];
188
+ };
189
+ for (const line of raw.split("\n")) {
190
+ const trimmed = line.trim();
191
+ if (!trimmed || trimmed.startsWith("#")) continue;
192
+ const m2 = trimmed.match(/^(\S+)[\s=]+(.+)$/);
193
+ if (!m2) continue;
194
+ const keyword = m2[1].toLowerCase();
195
+ const value = m2[2].trim();
196
+ if (keyword === "host") {
197
+ flush();
198
+ const aliases = value.split(/\s+/).filter((a) => a && !a.includes("*") && !a.includes("?") && !a.startsWith("!"));
199
+ current = aliases.map((alias) => ({ alias }));
200
+ } else if (keyword === "hostname") {
201
+ for (const h of current) h.hostName = value;
202
+ } else if (keyword === "user") {
203
+ for (const h of current) h.user = value;
204
+ }
205
+ }
206
+ flush();
207
+ const seen = /* @__PURE__ */ new Set();
208
+ return hosts.filter((h) => seen.has(h.alias) ? false : (seen.add(h.alias), true));
209
+ }
210
+ const RECENTS_FILE = "ssh-recents.json";
211
+ const MAX_RECENTS = 8;
212
+ function recentsPath(settingsDir) {
213
+ return path.join(settingsDir, RECENTS_FILE);
214
+ }
215
+ function getRecentHosts(settingsDir) {
216
+ try {
217
+ const raw = JSON.parse(fs.readFileSync(recentsPath(settingsDir), "utf-8"));
218
+ if (Array.isArray(raw)) return raw.filter((x) => typeof x === "string");
219
+ } catch {
220
+ }
221
+ return [];
222
+ }
223
+ function addRecentHost(settingsDir, target) {
224
+ const t = target.trim();
225
+ if (!t) return;
226
+ const next = [t, ...getRecentHosts(settingsDir).filter((x) => x !== t)].slice(0, MAX_RECENTS);
227
+ try {
228
+ fs.mkdirSync(settingsDir, { recursive: true });
229
+ fs.writeFileSync(recentsPath(settingsDir), JSON.stringify(next, null, 2));
230
+ } catch {
231
+ }
232
+ }
233
+ const PROBE_REMOTE_CMD = "command -v tmux >/dev/null 2>&1 && echo __TMUX_OK__; tmux ls 2>/dev/null; echo __DONE__";
234
+ function probeRemote(target) {
235
+ return new Promise((resolve) => {
236
+ const args = [
237
+ "-o",
238
+ "BatchMode=yes",
239
+ "-o",
240
+ "ConnectTimeout=8",
241
+ "-o",
242
+ "StrictHostKeyChecking=accept-new",
243
+ target,
244
+ PROBE_REMOTE_CMD
245
+ ];
246
+ let stdout = "";
247
+ let stderr = "";
248
+ let settled = false;
249
+ const done = (r) => {
250
+ if (settled) return;
251
+ settled = true;
252
+ resolve(r);
253
+ };
254
+ let child;
255
+ try {
256
+ child = child_process.spawn("ssh", args, { stdio: ["ignore", "pipe", "pipe"] });
257
+ } catch (err) {
258
+ done({ reachable: false, authRequired: false, error: err?.message || "spawn failed", tmuxInstalled: false, sessions: [], ctlsurfSessions: [] });
259
+ return;
260
+ }
261
+ const killTimer = setTimeout(() => {
262
+ try {
263
+ child.kill();
264
+ } catch {
265
+ }
266
+ done({ reachable: false, authRequired: false, error: "Timed out connecting", tmuxInstalled: false, sessions: [], ctlsurfSessions: [] });
267
+ }, 12e3);
268
+ child.stdout?.on("data", (d) => {
269
+ stdout += d.toString();
270
+ });
271
+ child.stderr?.on("data", (d) => {
272
+ stderr += d.toString();
273
+ });
274
+ child.on("error", (err) => {
275
+ clearTimeout(killTimer);
276
+ done({ reachable: false, authRequired: false, error: err.message, tmuxInstalled: false, sessions: [], ctlsurfSessions: [] });
277
+ });
278
+ child.on("close", (code) => {
279
+ clearTimeout(killTimer);
280
+ if (!stdout.includes("__DONE__")) {
281
+ const authRequired = /permission denied|password|publickey/i.test(stderr);
282
+ const error = authRequired ? "Key auth refused (password required)" : stderr.trim().split("\n").pop() || `ssh exited ${code}`;
283
+ done({ reachable: false, authRequired, error, tmuxInstalled: false, sessions: [], ctlsurfSessions: [] });
284
+ return;
285
+ }
286
+ const tmuxInstalled = stdout.includes("__TMUX_OK__");
287
+ const sessions = parseTmuxSessions(stdout);
288
+ done({
289
+ reachable: true,
290
+ authRequired: false,
291
+ tmuxInstalled,
292
+ sessions,
293
+ ctlsurfSessions: sessions.filter((s) => s.startsWith(TMUX_SESSION_PREFIX))
294
+ });
295
+ });
296
+ });
297
+ }
298
+ function stripAnsiSeq(s) {
299
+ return s.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "");
300
+ }
301
+ const PASSWORD_PROMPT_RE = /(?:password|passphrase)[^\n]*:\s*$/i;
302
+ function isPasswordPrompt(tail) {
303
+ return PASSWORD_PROMPT_RE.test(tail);
304
+ }
305
+ function parseTmuxSessions(stdout) {
306
+ const out = [];
307
+ for (const line of stdout.split("\n")) {
308
+ const t = line.trim();
309
+ if (!t || t.startsWith("__")) continue;
310
+ const m2 = t.match(/^([^:\s]+):/);
311
+ if (m2) out.push(m2[1]);
312
+ }
313
+ return out;
314
+ }
315
+ function shQuote(s) {
316
+ return `'${s.replace(/'/g, `'\\''`)}'`;
317
+ }
318
+ function remoteCommandFor(action, agentCmd) {
319
+ const inner = agentCmd.trim();
320
+ switch (action.kind) {
321
+ case "attach":
322
+ return `tmux attach -t ${shQuote(action.session)}`;
323
+ case "new-tmux":
324
+ return inner ? `tmux new-session -A -s ${shQuote(action.session)} ${shQuote(inner)}` : `tmux new-session -A -s ${shQuote(action.session)}`;
325
+ case "no-tmux":
326
+ return inner;
327
+ case "auto":
328
+ return inner ? `if command -v tmux >/dev/null 2>&1; then tmux new-session -A -s ${shQuote(action.session)} ${shQuote(inner)}; else ${inner}; fi` : `if command -v tmux >/dev/null 2>&1; then tmux new-session -A -s ${shQuote(action.session)}; else exec "$SHELL" -l; fi`;
329
+ }
330
+ }
331
+ function buildRemoteAgent(opts) {
332
+ const { target, action } = opts;
333
+ const agentCmd = opts.agentCmd ?? "claude";
334
+ const remoteCmd = remoteCommandFor(action, agentCmd);
335
+ const args = remoteCmd ? ["-t", target, remoteCmd] : ["-t", target];
336
+ const safeTarget = target.replace(/[^a-zA-Z0-9_.@-]/g, "_");
337
+ const label = action.kind === "attach" ? action.session : action.kind === "new-tmux" || action.kind === "auto" ? action.session : "direct";
338
+ const agentLabel = agentCmd.trim() || "shell";
339
+ const desc = action.kind === "attach" ? `SSH ${target} → attach tmux ${action.session}` : action.kind === "no-tmux" ? `SSH ${target} → ${agentLabel}` : `SSH ${target} → tmux ${"session" in action ? action.session : ""} → ${agentLabel}`;
340
+ return {
341
+ id: `remote-${safeTarget}-${label}`,
342
+ name: `${target} · ${label}`,
343
+ command: "ssh",
344
+ args,
345
+ description: desc
346
+ };
347
+ }
170
348
  const CTLSURF_BASE_URL = "https://app.ctlsurf.com/api";
171
349
  class CtlsurfApi {
172
350
  baseUrl;
@@ -227,6 +405,12 @@ class CtlsurfApi {
227
405
  async createFolder(params) {
228
406
  return this.request("POST", "/folders", params);
229
407
  }
408
+ // All folders (projects) the API key can see. Used to let a remote session
409
+ // pick which ctlsurf project to bind to.
410
+ async listFolders() {
411
+ const folders = await this.request("GET", "/folders");
412
+ return Array.isArray(folders) ? folders : [];
413
+ }
230
414
  // ─── Workers ────────────────────────────────────────
231
415
  async getAuthCode() {
232
416
  return this.request("POST", "/workers/token-exchange");
@@ -11295,6 +11479,10 @@ class Orchestrator {
11295
11479
  currentProjectName = null;
11296
11480
  transcriptTailer = null;
11297
11481
  stateMonitor;
11482
+ // One-shot SSH passwords entered in-app, keyed by target. Held only in memory
11483
+ // until the next remote spawn for that target consumes them — never persisted.
11484
+ pendingSshPasswords = /* @__PURE__ */ new Map();
11485
+ pendingSshPasswordTimers = /* @__PURE__ */ new Map();
11298
11486
  constructor(settingsDir, events) {
11299
11487
  this.settingsDir = settingsDir;
11300
11488
  this.events = events;
@@ -11590,6 +11778,53 @@ class Orchestrator {
11590
11778
  return { ok: true };
11591
11779
  }
11592
11780
  // ─── PTY & Agent (multi-tab) ─────────────────────
11781
+ // Stash an in-app SSH password for the next remote spawn to `target`. Kept
11782
+ // only in memory and auto-expired so a typed-but-unused password doesn't
11783
+ // linger. Never written to disk or logged.
11784
+ setPendingSshPassword(target, password) {
11785
+ if (!target || !password) return;
11786
+ const prev = this.pendingSshPasswordTimers.get(target);
11787
+ if (prev) clearTimeout(prev);
11788
+ this.pendingSshPasswords.set(target, password);
11789
+ this.pendingSshPasswordTimers.set(target, setTimeout(() => {
11790
+ this.pendingSshPasswords.delete(target);
11791
+ this.pendingSshPasswordTimers.delete(target);
11792
+ }, 12e4));
11793
+ }
11794
+ consumePendingSshPassword(target) {
11795
+ const pw = this.pendingSshPasswords.get(target);
11796
+ if (pw === void 0) return void 0;
11797
+ this.pendingSshPasswords.delete(target);
11798
+ const timer = this.pendingSshPasswordTimers.get(target);
11799
+ if (timer) {
11800
+ clearTimeout(timer);
11801
+ this.pendingSshPasswordTimers.delete(target);
11802
+ }
11803
+ return pw;
11804
+ }
11805
+ // Watch a freshly-spawned ssh PTY for its password/passphrase prompt and write
11806
+ // the password once. We write straight to the PTY (not through writePty) so it
11807
+ // never reaches the bridge/chat-logging path; ssh keeps echo off, so it never
11808
+ // renders to the terminal or scrollback either.
11809
+ armPasswordInjector(ptyManager, password) {
11810
+ let pw = password;
11811
+ let tail = "";
11812
+ const giveUp = setTimeout(() => {
11813
+ pw = null;
11814
+ tail = "";
11815
+ }, 3e4);
11816
+ ptyManager.onData((data) => {
11817
+ if (pw === null) return;
11818
+ tail = (tail + stripAnsiSeq(data)).slice(-200);
11819
+ if (isPasswordPrompt(tail)) {
11820
+ log$3("[ssh] password prompt detected — injecting in-app password (one-shot)");
11821
+ ptyManager.write(pw + "\r");
11822
+ pw = null;
11823
+ tail = "";
11824
+ clearTimeout(giveUp);
11825
+ }
11826
+ });
11827
+ }
11593
11828
  async spawnAgent(tabId, agent, cwd, opts) {
11594
11829
  const existing = this.tabs.get(tabId);
11595
11830
  if (existing) {
@@ -11608,6 +11843,14 @@ class Orchestrator {
11608
11843
  const tab = { ptyManager, agent, cwd, termStreamBuffer: "", termStreamTimer: null };
11609
11844
  this.tabs.set(tabId, tab);
11610
11845
  this.stateMonitor.trackTab(tabId, agent.id, cwd);
11846
+ if (isRemoteAgent(agent)) {
11847
+ const target = agent.args[1];
11848
+ const pw = target ? this.consumePendingSshPassword(target) : void 0;
11849
+ if (pw) {
11850
+ log$3(`[ssh] remote spawn for ${target}: in-app password armed (will inject at prompt)`);
11851
+ this.armPasswordInjector(ptyManager, pw);
11852
+ }
11853
+ }
11611
11854
  ptyManager.onData((data) => {
11612
11855
  this.events.onPtyData(tabId, data);
11613
11856
  this.timeTracker.recordActivity(tabId);
@@ -11681,12 +11924,16 @@ class Orchestrator {
11681
11924
  }
11682
11925
  }
11683
11926
  setActiveTab(tabId) {
11927
+ const prevProjectPath = this.getActiveTabProjectPath();
11684
11928
  this.activeTabId = tabId;
11685
11929
  const tab = this.tabs.get(tabId);
11686
11930
  if (tab) {
11687
11931
  this.currentAgent = tab.agent;
11688
11932
  this.currentCwd = tab.cwd;
11689
11933
  }
11934
+ if (this.getActiveTabProjectPath() !== prevProjectPath) {
11935
+ this.events.onCwdChanged();
11936
+ }
11690
11937
  }
11691
11938
  getTabIds() {
11692
11939
  return [...this.tabs.keys()];
@@ -11719,8 +11966,18 @@ class Orchestrator {
11719
11966
  if (!this.activeTabId) return null;
11720
11967
  return this.tabs.get(this.activeTabId)?.cwd ?? null;
11721
11968
  }
11969
+ /**
11970
+ * Path used to resolve the active tab's ctlsurf project. For a remote session
11971
+ * bound to a project this is the project's root_path; otherwise the local cwd.
11972
+ */
11973
+ getActiveTabProjectPath() {
11974
+ if (!this.activeTabId) return null;
11975
+ const tab = this.tabs.get(this.activeTabId);
11976
+ if (!tab) return null;
11977
+ return tab.projectRootPath ?? tab.cwd;
11978
+ }
11722
11979
  async addTicketForActiveTab(input) {
11723
- const cwd = this.getActiveTabCwd();
11980
+ const cwd = this.getActiveTabProjectPath();
11724
11981
  if (!cwd) return { ok: false, error: "No active terminal tab" };
11725
11982
  if (!this.ctlsurfApi.getApiKey()) {
11726
11983
  return { ok: false, error: "ctlsurf API key not configured" };
@@ -11728,7 +11985,7 @@ class Orchestrator {
11728
11985
  return this.ticketStore.addTicket(cwd, input);
11729
11986
  }
11730
11987
  async updateTicketForActiveTab(rowId, input) {
11731
- const cwd = this.getActiveTabCwd();
11988
+ const cwd = this.getActiveTabProjectPath();
11732
11989
  if (!cwd) return { ok: false, error: "No active terminal tab" };
11733
11990
  if (!this.ctlsurfApi.getApiKey()) {
11734
11991
  return { ok: false, error: "ctlsurf API key not configured" };
@@ -11736,7 +11993,7 @@ class Orchestrator {
11736
11993
  return this.ticketStore.updateTicket(cwd, rowId, input);
11737
11994
  }
11738
11995
  async listTicketsForActiveTab() {
11739
- const cwd = this.getActiveTabCwd();
11996
+ const cwd = this.getActiveTabProjectPath();
11740
11997
  if (!cwd) return { ok: false, tickets: [], error: "No active terminal tab" };
11741
11998
  if (!this.ctlsurfApi.getApiKey()) {
11742
11999
  return { ok: false, tickets: [], error: "ctlsurf API key not configured" };
@@ -11744,6 +12001,25 @@ class Orchestrator {
11744
12001
  return this.ticketStore.listTickets(cwd);
11745
12002
  }
11746
12003
  // ─── Worker WebSocket ───────────────────────────
12004
+ // Bind the active tab (typically a remote SSH session, whose local cwd is
12005
+ // meaningless) to a chosen ctlsurf project. We register the worker WS using
12006
+ // the project's root_path so the backend resolves that folder — the same
12007
+ // mechanism create-project relies on. This lights up chat logging, tickets,
12008
+ // remote-control, and the project header/panel for the remote session.
12009
+ bindActiveTabToProject(rootPath) {
12010
+ if (!rootPath) return { ok: false, error: "No project path" };
12011
+ if (!this.activeTabId) return { ok: false, error: "No active tab" };
12012
+ const tab = this.tabs.get(this.activeTabId);
12013
+ if (!tab) return { ok: false, error: "No active tab" };
12014
+ if (!this.ctlsurfApi.getApiKey()) return { ok: false, error: "No ctlsurf API key configured" };
12015
+ log$3(`[ctlsurf] Binding tab agent="${tab.agent.name}" to project root_path=${rootPath}`);
12016
+ tab.projectRootPath = rootPath;
12017
+ this.stopNoProjectPolling();
12018
+ this.workerWs.disconnect();
12019
+ this.connectWorkerWs(tab.agent, rootPath);
12020
+ this.events.onCwdChanged();
12021
+ return { ok: true };
12022
+ }
11747
12023
  connectWorkerWs(agent, cwd) {
11748
12024
  const profile = this.getActiveProfile();
11749
12025
  const apiKey = profile.apiKey || process.env.CTLSURF_API_KEY;
@@ -11853,6 +12129,7 @@ function log(...args) {
11853
12129
  } catch {
11854
12130
  }
11855
12131
  }
12132
+ const SETTINGS_DIR = getSettingsDir();
11856
12133
  let mainWindow = null;
11857
12134
  function getProjectAbbrev(cwdPath) {
11858
12135
  const folderName = cwdPath.split("/").filter(Boolean).pop() || "";
@@ -11981,7 +12258,7 @@ function stopAttention() {
11981
12258
  applySteadyBadge();
11982
12259
  }
11983
12260
  const orchestrator = new Orchestrator(
11984
- getSettingsDir(),
12261
+ SETTINGS_DIR,
11985
12262
  {
11986
12263
  onPtyData: (tabId, data) => mainWindow?.webContents.send("pty:data", tabId, data),
11987
12264
  onPtyExit: (tabId, code) => mainWindow?.webContents.send("pty:exit", tabId, code),
@@ -11997,7 +12274,7 @@ const orchestrator = new Orchestrator(
11997
12274
  },
11998
12275
  onCwdChanged: () => {
11999
12276
  mainWindow?.webContents.send("app:cwdChanged");
12000
- updateProjectBadge(orchestrator.cwd);
12277
+ updateProjectBadge(orchestrator.getActiveTabProjectPath() || orchestrator.cwd);
12001
12278
  }
12002
12279
  }
12003
12280
  );
@@ -12053,6 +12330,37 @@ electron.ipcMain.handle("pty:setActiveTab", (_event, tabId) => {
12053
12330
  });
12054
12331
  electron.ipcMain.handle("agents:list", () => getAvailableAgents());
12055
12332
  electron.ipcMain.handle("agents:default", () => getDefaultAgent());
12333
+ electron.ipcMain.handle("ssh:listHosts", () => ({
12334
+ configHosts: listSshHosts(),
12335
+ recents: getRecentHosts(SETTINGS_DIR)
12336
+ }));
12337
+ electron.ipcMain.handle("ssh:probe", (_event, target) => probeRemote(target));
12338
+ electron.ipcMain.handle(
12339
+ "ssh:buildAgent",
12340
+ (_event, target, action, agentCmd) => buildRemoteAgent({ target, action, agentCmd })
12341
+ );
12342
+ electron.ipcMain.handle("ssh:addRecent", (_event, target) => {
12343
+ addRecentHost(SETTINGS_DIR, target);
12344
+ return { ok: true };
12345
+ });
12346
+ electron.ipcMain.handle("ssh:setPassword", (_event, target, password) => {
12347
+ orchestrator.setPendingSshPassword(target, password);
12348
+ return { ok: true };
12349
+ });
12350
+ electron.ipcMain.handle("ctlsurf:listProjects", async () => {
12351
+ if (!orchestrator.ctlsurfApi.getApiKey()) return { ok: false, projects: [], error: "No ctlsurf API key configured" };
12352
+ try {
12353
+ const folders = await orchestrator.ctlsurfApi.listFolders();
12354
+ const projects = folders.filter((f) => f && f.id && f.root_path).map((f) => ({ id: f.id, name: f.name || "(unnamed)", root_path: f.root_path }));
12355
+ return { ok: true, projects };
12356
+ } catch (err) {
12357
+ return { ok: false, projects: [], error: err?.message || "Failed to list projects" };
12358
+ }
12359
+ });
12360
+ electron.ipcMain.handle(
12361
+ "ctlsurf:bindProject",
12362
+ (_event, rootPath) => orchestrator.bindActiveTabToProject(rootPath)
12363
+ );
12056
12364
  electron.ipcMain.handle("app:homePath", () => electron.app.getPath("home"));
12057
12365
  electron.ipcMain.handle("app:cwd", () => process.env.CTLSURF_WORKER_CWD || process.cwd());
12058
12366
  electron.ipcMain.handle("app:projectName", () => orchestrator.projectName);
@@ -12159,7 +12467,7 @@ electron.ipcMain.handle("worker:getWebviewInfo", async () => {
12159
12467
  try {
12160
12468
  const { code } = await orchestrator.ctlsurfApi.getAuthCode();
12161
12469
  let pageUrl = `${frontendUrl}?embed=1&_code=${encodeURIComponent(code)}`;
12162
- const currentCwd = orchestrator.cwd;
12470
+ const currentCwd = orchestrator.getActiveTabProjectPath() || orchestrator.cwd;
12163
12471
  if (currentCwd) {
12164
12472
  try {
12165
12473
  let folder = null;
@@ -12244,7 +12552,7 @@ electron.ipcMain.handle("tracking:set", async (_event, enabled) => {
12244
12552
  return { active: orchestrator.isActiveTabTracking() };
12245
12553
  });
12246
12554
  electron.ipcMain.handle("tickets:project", () => {
12247
- const cwd = orchestrator.getActiveTabCwd();
12555
+ const cwd = orchestrator.getActiveTabProjectPath();
12248
12556
  return { cwd, name: cwd ? cwd.split("/").filter(Boolean).pop() || cwd : null };
12249
12557
  });
12250
12558
  electron.ipcMain.handle("tickets:add", async (_event, input) => {
@@ -21,6 +21,15 @@ const api = {
21
21
  // Agent management
22
22
  listAgents: () => electron.ipcRenderer.invoke("agents:list"),
23
23
  getDefaultAgent: () => electron.ipcRenderer.invoke("agents:default"),
24
+ // Remote (SSH)
25
+ sshListHosts: () => electron.ipcRenderer.invoke("ssh:listHosts"),
26
+ sshProbe: (target) => electron.ipcRenderer.invoke("ssh:probe", target),
27
+ sshBuildAgent: (target, action, agentCmd) => electron.ipcRenderer.invoke("ssh:buildAgent", target, action, agentCmd),
28
+ sshAddRecent: (target) => electron.ipcRenderer.invoke("ssh:addRecent", target),
29
+ sshSetPassword: (target, password) => electron.ipcRenderer.invoke("ssh:setPassword", target, password),
30
+ // ctlsurf project selection (for remote sessions)
31
+ listProjects: () => electron.ipcRenderer.invoke("ctlsurf:listProjects"),
32
+ bindProject: (rootPath) => electron.ipcRenderer.invoke("ctlsurf:bindProject", rootPath),
24
33
  // App info
25
34
  getHomePath: () => electron.ipcRenderer.invoke("app:homePath"),
26
35
  getCwd: () => electron.ipcRenderer.invoke("app:cwd"),
@@ -1,6 +1,6 @@
1
- import { c as createWebWorker, l as languages } from "./index-BDlCPzCG.js";
2
- import { C as CompletionAdapter, H as HoverAdapter, D as DocumentHighlightAdapter, a as DefinitionAdapter, R as ReferenceAdapter, b as DocumentSymbolAdapter, c as RenameAdapter, d as DocumentColorAdapter, F as FoldingRangeAdapter, e as DiagnosticsAdapter, S as SelectionRangeAdapter, f as DocumentFormattingEditProvider, g as DocumentRangeFormattingEditProvider } from "./lspLanguageFeatures-Bhdn2Eqy.js";
3
- import { h, i, j, t, k } from "./lspLanguageFeatures-Bhdn2Eqy.js";
1
+ import { c as createWebWorker, l as languages } from "./index-DxhW1emk.js";
2
+ import { C as CompletionAdapter, H as HoverAdapter, D as DocumentHighlightAdapter, a as DefinitionAdapter, R as ReferenceAdapter, b as DocumentSymbolAdapter, c as RenameAdapter, d as DocumentColorAdapter, F as FoldingRangeAdapter, e as DiagnosticsAdapter, S as SelectionRangeAdapter, f as DocumentFormattingEditProvider, g as DocumentRangeFormattingEditProvider } from "./lspLanguageFeatures-DGifVYB5.js";
3
+ import { h, i, j, t, k } from "./lspLanguageFeatures-DGifVYB5.js";
4
4
  const STOP_WHEN_IDLE_FOR = 2 * 60 * 1e3;
5
5
  class WorkerManager {
6
6
  constructor(defaults) {
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-BDlCPzCG.js";
1
+ import { l as languages } from "./index-DxhW1emk.js";
2
2
  const EMPTY_ELEMENTS = [
3
3
  "assign",
4
4
  "flush",
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-BDlCPzCG.js";
1
+ import { l as languages } from "./index-DxhW1emk.js";
2
2
  const EMPTY_ELEMENTS = [
3
3
  "area",
4
4
  "base",
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-BDlCPzCG.js";
1
+ import { l as languages } from "./index-DxhW1emk.js";
2
2
  const EMPTY_ELEMENTS = [
3
3
  "area",
4
4
  "base",
@@ -1,6 +1,6 @@
1
- import { c as createWebWorker, l as languages } from "./index-BDlCPzCG.js";
2
- import { H as HoverAdapter, D as DocumentHighlightAdapter, h as DocumentLinkAdapter, F as FoldingRangeAdapter, b as DocumentSymbolAdapter, S as SelectionRangeAdapter, c as RenameAdapter, f as DocumentFormattingEditProvider, g as DocumentRangeFormattingEditProvider, C as CompletionAdapter } from "./lspLanguageFeatures-Bhdn2Eqy.js";
3
- import { a, e, d, R, i, j, t, k } from "./lspLanguageFeatures-Bhdn2Eqy.js";
1
+ import { c as createWebWorker, l as languages } from "./index-DxhW1emk.js";
2
+ import { H as HoverAdapter, D as DocumentHighlightAdapter, h as DocumentLinkAdapter, F as FoldingRangeAdapter, b as DocumentSymbolAdapter, S as SelectionRangeAdapter, c as RenameAdapter, f as DocumentFormattingEditProvider, g as DocumentRangeFormattingEditProvider, C as CompletionAdapter } from "./lspLanguageFeatures-DGifVYB5.js";
3
+ import { a, e, d, R, i, j, t, k } from "./lspLanguageFeatures-DGifVYB5.js";
4
4
  const STOP_WHEN_IDLE_FOR = 2 * 60 * 1e3;
5
5
  class WorkerManager {
6
6
  constructor(defaults) {