@phenx-inc/ctlsurf 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/out/headless/index.mjs +111 -5
- package/out/headless/index.mjs.map +3 -3
- package/out/main/index.js +357 -28
- package/out/preload/index.js +9 -0
- package/out/renderer/assets/{cssMode-BbCVOTaJ.js → cssMode-R8fTFLae.js} +3 -3
- package/out/renderer/assets/{freemarker2-DryLywQv.js → freemarker2-CyjWzWoP.js} +1 -1
- package/out/renderer/assets/{handlebars-CdUMtSdr.js → handlebars-CrhCGIRM.js} +1 -1
- package/out/renderer/assets/{html-Cwkf2GMH.js → html-Cc3dPNZr.js} +1 -1
- package/out/renderer/assets/{htmlMode-C7PzhXxH.js → htmlMode-BWtG6ywk.js} +3 -3
- package/out/renderer/assets/{index-BIceGcz3.css → index-BL9njXIL.css} +206 -46
- package/out/renderer/assets/{index-Dg1vMspf.js → index-kkG56aKv.js} +529 -156
- package/out/renderer/assets/{javascript-Cy7ROXJl.js → javascript-C8T_SdLC.js} +2 -2
- package/out/renderer/assets/{jsonMode-BtZtt3QW.js → jsonMode-D1zR4VVU.js} +3 -3
- package/out/renderer/assets/{liquid-xReAQeYs.js → liquid-COSMe9Uf.js} +1 -1
- package/out/renderer/assets/{lspLanguageFeatures-Dmx0L0Bw.js → lspLanguageFeatures-CXGfrtzl.js} +1 -1
- package/out/renderer/assets/{mdx-YMNFO1Zz.js → mdx-RfS44T-L.js} +1 -1
- package/out/renderer/assets/{python-BExaDVxv.js → python-DFSWKHUu.js} +1 -1
- package/out/renderer/assets/{razor-HL8ANmQ4.js → razor-DtH2_uvo.js} +1 -1
- package/out/renderer/assets/{tsMode-Dqd8vdLE.js → tsMode-BYIQ-S9Z.js} +1 -1
- package/out/renderer/assets/{typescript-Dysz1PTX.js → typescript-HhQUr1Rb.js} +1 -1
- package/out/renderer/assets/{xml-BHp5Z9nc.js → xml-KuhgdKfD.js} +1 -1
- package/out/renderer/assets/{yaml-CPDHxVup.js → yaml-W_kzCFH7.js} +1 -1
- package/out/renderer/index.html +2 -2
- package/package.json +1 -1
- package/src/main/agents.ts +9 -1
- package/src/main/ctlsurfApi.ts +7 -0
- package/src/main/index.ts +118 -34
- package/src/main/orchestrator.ts +105 -4
- package/src/main/ssh.ts +348 -0
- package/src/preload/index.ts +26 -0
- package/src/renderer/App.tsx +29 -7
- package/src/renderer/components/AgentPicker.tsx +18 -0
- package/src/renderer/components/ProjectPicker.tsx +121 -0
- package/src/renderer/components/RemoteConnect.tsx +315 -0
- package/src/renderer/components/TicketPanel.tsx +20 -25
- package/src/renderer/styles.css +206 -46
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);
|
|
@@ -11719,8 +11962,18 @@ class Orchestrator {
|
|
|
11719
11962
|
if (!this.activeTabId) return null;
|
|
11720
11963
|
return this.tabs.get(this.activeTabId)?.cwd ?? null;
|
|
11721
11964
|
}
|
|
11965
|
+
/**
|
|
11966
|
+
* Path used to resolve the active tab's ctlsurf project. For a remote session
|
|
11967
|
+
* bound to a project this is the project's root_path; otherwise the local cwd.
|
|
11968
|
+
*/
|
|
11969
|
+
getActiveTabProjectPath() {
|
|
11970
|
+
if (!this.activeTabId) return null;
|
|
11971
|
+
const tab = this.tabs.get(this.activeTabId);
|
|
11972
|
+
if (!tab) return null;
|
|
11973
|
+
return tab.projectRootPath ?? tab.cwd;
|
|
11974
|
+
}
|
|
11722
11975
|
async addTicketForActiveTab(input) {
|
|
11723
|
-
const cwd = this.
|
|
11976
|
+
const cwd = this.getActiveTabProjectPath();
|
|
11724
11977
|
if (!cwd) return { ok: false, error: "No active terminal tab" };
|
|
11725
11978
|
if (!this.ctlsurfApi.getApiKey()) {
|
|
11726
11979
|
return { ok: false, error: "ctlsurf API key not configured" };
|
|
@@ -11728,7 +11981,7 @@ class Orchestrator {
|
|
|
11728
11981
|
return this.ticketStore.addTicket(cwd, input);
|
|
11729
11982
|
}
|
|
11730
11983
|
async updateTicketForActiveTab(rowId, input) {
|
|
11731
|
-
const cwd = this.
|
|
11984
|
+
const cwd = this.getActiveTabProjectPath();
|
|
11732
11985
|
if (!cwd) return { ok: false, error: "No active terminal tab" };
|
|
11733
11986
|
if (!this.ctlsurfApi.getApiKey()) {
|
|
11734
11987
|
return { ok: false, error: "ctlsurf API key not configured" };
|
|
@@ -11736,7 +11989,7 @@ class Orchestrator {
|
|
|
11736
11989
|
return this.ticketStore.updateTicket(cwd, rowId, input);
|
|
11737
11990
|
}
|
|
11738
11991
|
async listTicketsForActiveTab() {
|
|
11739
|
-
const cwd = this.
|
|
11992
|
+
const cwd = this.getActiveTabProjectPath();
|
|
11740
11993
|
if (!cwd) return { ok: false, tickets: [], error: "No active terminal tab" };
|
|
11741
11994
|
if (!this.ctlsurfApi.getApiKey()) {
|
|
11742
11995
|
return { ok: false, tickets: [], error: "ctlsurf API key not configured" };
|
|
@@ -11744,6 +11997,25 @@ class Orchestrator {
|
|
|
11744
11997
|
return this.ticketStore.listTickets(cwd);
|
|
11745
11998
|
}
|
|
11746
11999
|
// ─── Worker WebSocket ───────────────────────────
|
|
12000
|
+
// Bind the active tab (typically a remote SSH session, whose local cwd is
|
|
12001
|
+
// meaningless) to a chosen ctlsurf project. We register the worker WS using
|
|
12002
|
+
// the project's root_path so the backend resolves that folder — the same
|
|
12003
|
+
// mechanism create-project relies on. This lights up chat logging, tickets,
|
|
12004
|
+
// remote-control, and the project header/panel for the remote session.
|
|
12005
|
+
bindActiveTabToProject(rootPath) {
|
|
12006
|
+
if (!rootPath) return { ok: false, error: "No project path" };
|
|
12007
|
+
if (!this.activeTabId) return { ok: false, error: "No active tab" };
|
|
12008
|
+
const tab = this.tabs.get(this.activeTabId);
|
|
12009
|
+
if (!tab) return { ok: false, error: "No active tab" };
|
|
12010
|
+
if (!this.ctlsurfApi.getApiKey()) return { ok: false, error: "No ctlsurf API key configured" };
|
|
12011
|
+
log$3(`[ctlsurf] Binding tab agent="${tab.agent.name}" to project root_path=${rootPath}`);
|
|
12012
|
+
tab.projectRootPath = rootPath;
|
|
12013
|
+
this.stopNoProjectPolling();
|
|
12014
|
+
this.workerWs.disconnect();
|
|
12015
|
+
this.connectWorkerWs(tab.agent, rootPath);
|
|
12016
|
+
this.events.onCwdChanged();
|
|
12017
|
+
return { ok: true };
|
|
12018
|
+
}
|
|
11747
12019
|
connectWorkerWs(agent, cwd) {
|
|
11748
12020
|
const profile = this.getActiveProfile();
|
|
11749
12021
|
const apiKey = profile.apiKey || process.env.CTLSURF_API_KEY;
|
|
@@ -11853,6 +12125,7 @@ function log(...args) {
|
|
|
11853
12125
|
} catch {
|
|
11854
12126
|
}
|
|
11855
12127
|
}
|
|
12128
|
+
const SETTINGS_DIR = getSettingsDir();
|
|
11856
12129
|
let mainWindow = null;
|
|
11857
12130
|
function getProjectAbbrev(cwdPath) {
|
|
11858
12131
|
const folderName = cwdPath.split("/").filter(Boolean).pop() || "";
|
|
@@ -11865,10 +12138,10 @@ function getProjectAbbrev(cwdPath) {
|
|
|
11865
12138
|
if (word.length <= 3) return word.toUpperCase();
|
|
11866
12139
|
return word.slice(0, 2).toUpperCase();
|
|
11867
12140
|
}
|
|
11868
|
-
function createDotOverlay(
|
|
12141
|
+
function createDotOverlay(ready) {
|
|
11869
12142
|
const size = 32;
|
|
11870
12143
|
const canvas = Buffer.alloc(size * size * 4, 0);
|
|
11871
|
-
const [r, g2, b2] =
|
|
12144
|
+
const [r, g2, b2] = ready ? [52, 199, 89] : [122, 162, 247];
|
|
11872
12145
|
for (let y2 = 0; y2 < size; y2++) {
|
|
11873
12146
|
for (let x = 0; x < size; x++) {
|
|
11874
12147
|
const cx = x - size / 2;
|
|
@@ -11884,6 +12157,37 @@ function createDotOverlay(red) {
|
|
|
11884
12157
|
}
|
|
11885
12158
|
return electron.nativeImage.createFromBuffer(canvas, { width: size, height: size });
|
|
11886
12159
|
}
|
|
12160
|
+
let baseDockIcon = null;
|
|
12161
|
+
function dockIconWithReadyDot(base) {
|
|
12162
|
+
const { width, height } = base.getSize();
|
|
12163
|
+
if (!width || !height) return base;
|
|
12164
|
+
const bmp = base.toBitmap();
|
|
12165
|
+
const r = Math.round(Math.min(width, height) * 0.17);
|
|
12166
|
+
const ring = Math.max(2, Math.round(r * 0.22));
|
|
12167
|
+
const cx = width - r - Math.round(width * 0.05);
|
|
12168
|
+
const cy = height - r - Math.round(height * 0.05);
|
|
12169
|
+
const put = (x, y2, b2, g2, rr) => {
|
|
12170
|
+
const idx = (y2 * width + x) * 4;
|
|
12171
|
+
bmp[idx] = b2;
|
|
12172
|
+
bmp[idx + 1] = g2;
|
|
12173
|
+
bmp[idx + 2] = rr;
|
|
12174
|
+
bmp[idx + 3] = 255;
|
|
12175
|
+
};
|
|
12176
|
+
for (let y2 = cy - r - ring; y2 <= cy + r + ring; y2++) {
|
|
12177
|
+
if (y2 < 0 || y2 >= height) continue;
|
|
12178
|
+
for (let x = cx - r - ring; x <= cx + r + ring; x++) {
|
|
12179
|
+
if (x < 0 || x >= width) continue;
|
|
12180
|
+
const d = Math.sqrt((x - cx) ** 2 + (y2 - cy) ** 2);
|
|
12181
|
+
if (d <= r) put(x, y2, 89, 199, 52);
|
|
12182
|
+
else if (d <= r + ring) put(x, y2, 24, 26, 31);
|
|
12183
|
+
}
|
|
12184
|
+
}
|
|
12185
|
+
return electron.nativeImage.createFromBitmap(bmp, { width, height });
|
|
12186
|
+
}
|
|
12187
|
+
function setDockReadyDot(on) {
|
|
12188
|
+
if (process.platform !== "darwin" || !electron.app.dock || !baseDockIcon) return;
|
|
12189
|
+
electron.app.dock.setIcon(on ? dockIconWithReadyDot(baseDockIcon) : baseDockIcon);
|
|
12190
|
+
}
|
|
11887
12191
|
let projectAbbrev = "";
|
|
11888
12192
|
function applySteadyBadge() {
|
|
11889
12193
|
if (process.platform === "darwin" && electron.app.dock) {
|
|
@@ -11896,15 +12200,14 @@ function applySteadyBadge() {
|
|
|
11896
12200
|
function updateProjectBadge(cwdPath) {
|
|
11897
12201
|
if (!cwdPath) return;
|
|
11898
12202
|
projectAbbrev = getProjectAbbrev(cwdPath);
|
|
11899
|
-
|
|
12203
|
+
applySteadyBadge();
|
|
11900
12204
|
if (mainWindow) {
|
|
11901
12205
|
const folderName = cwdPath.split("/").filter(Boolean).pop() || "ctlsurf-worker";
|
|
11902
12206
|
mainWindow.setTitle(`ctlsurf-worker — ${folderName}`);
|
|
11903
12207
|
}
|
|
11904
12208
|
}
|
|
11905
12209
|
const attention = new AttentionController();
|
|
11906
|
-
let
|
|
11907
|
-
let blinkOn = false;
|
|
12210
|
+
let attentionActive = false;
|
|
11908
12211
|
let bounceId = -1;
|
|
11909
12212
|
function setAwaitingIndicator(on) {
|
|
11910
12213
|
applyAttention(attention.setAwaiting(on));
|
|
@@ -11914,34 +12217,28 @@ function applyAttention(active) {
|
|
|
11914
12217
|
else stopAttention();
|
|
11915
12218
|
}
|
|
11916
12219
|
function startAttention() {
|
|
11917
|
-
if (
|
|
11918
|
-
|
|
12220
|
+
if (attentionActive) return;
|
|
12221
|
+
attentionActive = true;
|
|
12222
|
+
log("[agent-state] attention START (green ready dot + bounce)");
|
|
11919
12223
|
if (process.platform === "darwin" && electron.app.dock) {
|
|
12224
|
+
setDockReadyDot(true);
|
|
11920
12225
|
bounceId = electron.app.dock.bounce("informational");
|
|
11921
12226
|
}
|
|
11922
12227
|
if (process.platform === "win32" && mainWindow) {
|
|
11923
|
-
mainWindow.setOverlayIcon(createDotOverlay(true), "
|
|
12228
|
+
mainWindow.setOverlayIcon(createDotOverlay(true), "Ready for you");
|
|
11924
12229
|
mainWindow.flashFrame(true);
|
|
11925
12230
|
}
|
|
11926
12231
|
if (process.platform === "linux") {
|
|
11927
12232
|
if (typeof electron.app.badgeCount === "number") electron.app.badgeCount = 1;
|
|
11928
12233
|
mainWindow?.flashFrame(true);
|
|
11929
12234
|
}
|
|
11930
|
-
blinkOn = true;
|
|
11931
|
-
blinkTimer = setInterval(() => {
|
|
11932
|
-
blinkOn = !blinkOn;
|
|
11933
|
-
if (process.platform === "darwin" && electron.app.dock) {
|
|
11934
|
-
electron.app.dock.setBadge(blinkOn ? projectAbbrev || "●" : "");
|
|
11935
|
-
}
|
|
11936
|
-
}, 600);
|
|
11937
12235
|
}
|
|
11938
12236
|
function stopAttention() {
|
|
11939
|
-
if (
|
|
11940
|
-
|
|
11941
|
-
|
|
11942
|
-
log("[agent-state] attention STOP");
|
|
11943
|
-
}
|
|
12237
|
+
if (!attentionActive) return;
|
|
12238
|
+
attentionActive = false;
|
|
12239
|
+
log("[agent-state] attention STOP");
|
|
11944
12240
|
if (process.platform === "darwin" && electron.app.dock) {
|
|
12241
|
+
setDockReadyDot(false);
|
|
11945
12242
|
if (bounceId >= 0) {
|
|
11946
12243
|
electron.app.dock.cancelBounce(bounceId);
|
|
11947
12244
|
bounceId = -1;
|
|
@@ -11957,7 +12254,7 @@ function stopAttention() {
|
|
|
11957
12254
|
applySteadyBadge();
|
|
11958
12255
|
}
|
|
11959
12256
|
const orchestrator = new Orchestrator(
|
|
11960
|
-
|
|
12257
|
+
SETTINGS_DIR,
|
|
11961
12258
|
{
|
|
11962
12259
|
onPtyData: (tabId, data) => mainWindow?.webContents.send("pty:data", tabId, data),
|
|
11963
12260
|
onPtyExit: (tabId, code) => mainWindow?.webContents.send("pty:exit", tabId, code),
|
|
@@ -12029,6 +12326,37 @@ electron.ipcMain.handle("pty:setActiveTab", (_event, tabId) => {
|
|
|
12029
12326
|
});
|
|
12030
12327
|
electron.ipcMain.handle("agents:list", () => getAvailableAgents());
|
|
12031
12328
|
electron.ipcMain.handle("agents:default", () => getDefaultAgent());
|
|
12329
|
+
electron.ipcMain.handle("ssh:listHosts", () => ({
|
|
12330
|
+
configHosts: listSshHosts(),
|
|
12331
|
+
recents: getRecentHosts(SETTINGS_DIR)
|
|
12332
|
+
}));
|
|
12333
|
+
electron.ipcMain.handle("ssh:probe", (_event, target) => probeRemote(target));
|
|
12334
|
+
electron.ipcMain.handle(
|
|
12335
|
+
"ssh:buildAgent",
|
|
12336
|
+
(_event, target, action, agentCmd) => buildRemoteAgent({ target, action, agentCmd })
|
|
12337
|
+
);
|
|
12338
|
+
electron.ipcMain.handle("ssh:addRecent", (_event, target) => {
|
|
12339
|
+
addRecentHost(SETTINGS_DIR, target);
|
|
12340
|
+
return { ok: true };
|
|
12341
|
+
});
|
|
12342
|
+
electron.ipcMain.handle("ssh:setPassword", (_event, target, password) => {
|
|
12343
|
+
orchestrator.setPendingSshPassword(target, password);
|
|
12344
|
+
return { ok: true };
|
|
12345
|
+
});
|
|
12346
|
+
electron.ipcMain.handle("ctlsurf:listProjects", async () => {
|
|
12347
|
+
if (!orchestrator.ctlsurfApi.getApiKey()) return { ok: false, projects: [], error: "No ctlsurf API key configured" };
|
|
12348
|
+
try {
|
|
12349
|
+
const folders = await orchestrator.ctlsurfApi.listFolders();
|
|
12350
|
+
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 }));
|
|
12351
|
+
return { ok: true, projects };
|
|
12352
|
+
} catch (err) {
|
|
12353
|
+
return { ok: false, projects: [], error: err?.message || "Failed to list projects" };
|
|
12354
|
+
}
|
|
12355
|
+
});
|
|
12356
|
+
electron.ipcMain.handle(
|
|
12357
|
+
"ctlsurf:bindProject",
|
|
12358
|
+
(_event, rootPath) => orchestrator.bindActiveTabToProject(rootPath)
|
|
12359
|
+
);
|
|
12032
12360
|
electron.ipcMain.handle("app:homePath", () => electron.app.getPath("home"));
|
|
12033
12361
|
electron.ipcMain.handle("app:cwd", () => process.env.CTLSURF_WORKER_CWD || process.cwd());
|
|
12034
12362
|
electron.ipcMain.handle("app:projectName", () => orchestrator.projectName);
|
|
@@ -12135,7 +12463,7 @@ electron.ipcMain.handle("worker:getWebviewInfo", async () => {
|
|
|
12135
12463
|
try {
|
|
12136
12464
|
const { code } = await orchestrator.ctlsurfApi.getAuthCode();
|
|
12137
12465
|
let pageUrl = `${frontendUrl}?embed=1&_code=${encodeURIComponent(code)}`;
|
|
12138
|
-
const currentCwd = orchestrator.cwd;
|
|
12466
|
+
const currentCwd = orchestrator.getActiveTabProjectPath() || orchestrator.cwd;
|
|
12139
12467
|
if (currentCwd) {
|
|
12140
12468
|
try {
|
|
12141
12469
|
let folder = null;
|
|
@@ -12220,7 +12548,7 @@ electron.ipcMain.handle("tracking:set", async (_event, enabled) => {
|
|
|
12220
12548
|
return { active: orchestrator.isActiveTabTracking() };
|
|
12221
12549
|
});
|
|
12222
12550
|
electron.ipcMain.handle("tickets:project", () => {
|
|
12223
|
-
const cwd = orchestrator.
|
|
12551
|
+
const cwd = orchestrator.getActiveTabProjectPath();
|
|
12224
12552
|
return { cwd, name: cwd ? cwd.split("/").filter(Boolean).pop() || cwd : null };
|
|
12225
12553
|
});
|
|
12226
12554
|
electron.ipcMain.handle("tickets:add", async (_event, input) => {
|
|
@@ -12266,7 +12594,8 @@ electron.app.whenReady().then(() => {
|
|
|
12266
12594
|
if (process.platform === "darwin" && electron.app.dock) {
|
|
12267
12595
|
const iconPath = path.join(__dirname, "../../resources/icon.png");
|
|
12268
12596
|
try {
|
|
12269
|
-
electron.
|
|
12597
|
+
baseDockIcon = electron.nativeImage.createFromPath(iconPath);
|
|
12598
|
+
electron.app.dock.setIcon(baseDockIcon);
|
|
12270
12599
|
} catch {
|
|
12271
12600
|
}
|
|
12272
12601
|
}
|
package/out/preload/index.js
CHANGED
|
@@ -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-
|
|
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-
|
|
3
|
-
import { h, i, j, t, k } from "./lspLanguageFeatures-
|
|
1
|
+
import { c as createWebWorker, l as languages } from "./index-kkG56aKv.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-CXGfrtzl.js";
|
|
3
|
+
import { h, i, j, t, k } from "./lspLanguageFeatures-CXGfrtzl.js";
|
|
4
4
|
const STOP_WHEN_IDLE_FOR = 2 * 60 * 1e3;
|
|
5
5
|
class WorkerManager {
|
|
6
6
|
constructor(defaults) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { c as createWebWorker, l as languages } from "./index-
|
|
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-
|
|
3
|
-
import { a, e, d, R, i, j, t, k } from "./lspLanguageFeatures-
|
|
1
|
+
import { c as createWebWorker, l as languages } from "./index-kkG56aKv.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-CXGfrtzl.js";
|
|
3
|
+
import { a, e, d, R, i, j, t, k } from "./lspLanguageFeatures-CXGfrtzl.js";
|
|
4
4
|
const STOP_WHEN_IDLE_FOR = 2 * 60 * 1e3;
|
|
5
5
|
class WorkerManager {
|
|
6
6
|
constructor(defaults) {
|