@phenx-inc/ctlsurf 0.11.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 +311 -7
- package/out/preload/index.js +9 -0
- package/out/renderer/assets/{cssMode-Bmzy9hKX.js → cssMode-R8fTFLae.js} +3 -3
- package/out/renderer/assets/{freemarker2-CDAsExTa.js → freemarker2-CyjWzWoP.js} +1 -1
- package/out/renderer/assets/{handlebars-CvpBYG1L.js → handlebars-CrhCGIRM.js} +1 -1
- package/out/renderer/assets/{html-CNhEQqr8.js → html-Cc3dPNZr.js} +1 -1
- package/out/renderer/assets/{htmlMode-DD7YPgga.js → htmlMode-BWtG6ywk.js} +3 -3
- package/out/renderer/assets/{index-CGHeDwZa.css → index-BL9njXIL.css} +201 -0
- package/out/renderer/assets/{index-BDlCPzCG.js → index-kkG56aKv.js} +407 -34
- package/out/renderer/assets/{javascript-CsGLsswK.js → javascript-C8T_SdLC.js} +2 -2
- package/out/renderer/assets/{jsonMode-CTuGgRvC.js → jsonMode-D1zR4VVU.js} +3 -3
- package/out/renderer/assets/{liquid-BrrN4uul.js → liquid-COSMe9Uf.js} +1 -1
- package/out/renderer/assets/{lspLanguageFeatures-Bhdn2Eqy.js → lspLanguageFeatures-CXGfrtzl.js} +1 -1
- package/out/renderer/assets/{mdx-DJo-gax4.js → mdx-RfS44T-L.js} +1 -1
- package/out/renderer/assets/{python-DYHeg5b2.js → python-DFSWKHUu.js} +1 -1
- package/out/renderer/assets/{razor-Cqcks14K.js → razor-DtH2_uvo.js} +1 -1
- package/out/renderer/assets/{tsMode-YTLgCayh.js → tsMode-BYIQ-S9Z.js} +1 -1
- package/out/renderer/assets/{typescript-oxBn_Vcw.js → typescript-HhQUr1Rb.js} +1 -1
- package/out/renderer/assets/{xml-BEqGMCTc.js → xml-KuhgdKfD.js} +1 -1
- package/out/renderer/assets/{yaml-BGSrk5HC.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 +58 -3
- 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 +20 -0
- 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/styles.css +201 -0
package/src/main/ssh.ts
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote SSH support
|
|
3
|
+
*
|
|
4
|
+
* Lets a tab run a coding agent on a remote machine by spawning `ssh` in the
|
|
5
|
+
* local PTY (see pty.ts) instead of the agent binary directly. Because every
|
|
6
|
+
* byte still flows through the PTY, the agent-state monitor (agentState.ts)
|
|
7
|
+
* keeps driving the dock "awaiting input" indicator for remote agents.
|
|
8
|
+
*
|
|
9
|
+
* Design notes:
|
|
10
|
+
* - Hosts come from the user's own ~/.ssh/config (key-based auth). A short MRU
|
|
11
|
+
* list of recently-used targets is kept for quick reconnect.
|
|
12
|
+
* - We never store passwords. For hosts that need a password, ssh's own prompt
|
|
13
|
+
* collects it over the PTY — it never passes through our code/args/env/disk.
|
|
14
|
+
* That also means we can't enumerate tmux sessions non-interactively for such
|
|
15
|
+
* hosts (BatchMode is rejected), so we fall back to an attach-or-create
|
|
16
|
+
* bootstrap that runs entirely on the remote.
|
|
17
|
+
* - tmux keeps the remote agent alive across SSH drops; sessions are named
|
|
18
|
+
* `ctlsurf_N` so several can coexist and be reattached.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import fs from 'fs'
|
|
22
|
+
import os from 'os'
|
|
23
|
+
import path from 'path'
|
|
24
|
+
import { spawn } from 'child_process'
|
|
25
|
+
import { AgentConfig } from './agents'
|
|
26
|
+
|
|
27
|
+
/** Prefix for tmux sessions this app creates, so reconnects can find them. */
|
|
28
|
+
export const TMUX_SESSION_PREFIX = 'ctlsurf_'
|
|
29
|
+
|
|
30
|
+
export interface SshHost {
|
|
31
|
+
/** The `Host` alias as written in ~/.ssh/config (what you pass to `ssh`). */
|
|
32
|
+
alias: string
|
|
33
|
+
/** Resolved HostName, if the config specifies one (for display only). */
|
|
34
|
+
hostName?: string
|
|
35
|
+
/** Configured User, if any (for display only). */
|
|
36
|
+
user?: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface SshHostList {
|
|
40
|
+
configHosts: SshHost[]
|
|
41
|
+
recents: string[]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ProbeResult {
|
|
45
|
+
/** A usable, key-authenticated connection was established. */
|
|
46
|
+
reachable: boolean
|
|
47
|
+
/**
|
|
48
|
+
* Reachable network-wise but key auth was refused (host likely wants a
|
|
49
|
+
* password). We can't enumerate tmux here; the UI should offer the
|
|
50
|
+
* interactive attach-or-create path instead.
|
|
51
|
+
*/
|
|
52
|
+
authRequired: boolean
|
|
53
|
+
/** Human-readable reason when reachable is false. */
|
|
54
|
+
error?: string
|
|
55
|
+
/** Whether tmux is installed on the remote. */
|
|
56
|
+
tmuxInstalled: boolean
|
|
57
|
+
/** All tmux session names found on the remote. */
|
|
58
|
+
sessions: string[]
|
|
59
|
+
/** Sessions created by this app (prefix `ctlsurf_`). */
|
|
60
|
+
ctlsurfSessions: string[]
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ─── ~/.ssh/config parsing ─────────────────────────
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Parse ~/.ssh/config into concrete Host aliases. Wildcard patterns (`*`, `?`)
|
|
67
|
+
* are skipped — they're match rules, not connectable targets. Negated tokens
|
|
68
|
+
* (`!pattern`) are ignored too. Best-effort: unknown directives are skipped and
|
|
69
|
+
* a malformed file yields an empty list rather than throwing.
|
|
70
|
+
*/
|
|
71
|
+
export function listSshHosts(configPath?: string): SshHost[] {
|
|
72
|
+
const file = configPath || path.join(os.homedir(), '.ssh', 'config')
|
|
73
|
+
let raw: string
|
|
74
|
+
try {
|
|
75
|
+
raw = fs.readFileSync(file, 'utf-8')
|
|
76
|
+
} catch {
|
|
77
|
+
return []
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const hosts: SshHost[] = []
|
|
81
|
+
// Aliases declared on the current `Host` line; later HostName/User apply to all.
|
|
82
|
+
let current: SshHost[] = []
|
|
83
|
+
|
|
84
|
+
const flush = () => {
|
|
85
|
+
for (const h of current) hosts.push(h)
|
|
86
|
+
current = []
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
for (const line of raw.split('\n')) {
|
|
90
|
+
const trimmed = line.trim()
|
|
91
|
+
if (!trimmed || trimmed.startsWith('#')) continue
|
|
92
|
+
|
|
93
|
+
// Directives are "Keyword value..." separated by whitespace or '='.
|
|
94
|
+
const m = trimmed.match(/^(\S+)[\s=]+(.+)$/)
|
|
95
|
+
if (!m) continue
|
|
96
|
+
const keyword = m[1].toLowerCase()
|
|
97
|
+
const value = m[2].trim()
|
|
98
|
+
|
|
99
|
+
if (keyword === 'host') {
|
|
100
|
+
flush()
|
|
101
|
+
const aliases = value
|
|
102
|
+
.split(/\s+/)
|
|
103
|
+
.filter((a) => a && !a.includes('*') && !a.includes('?') && !a.startsWith('!'))
|
|
104
|
+
current = aliases.map((alias) => ({ alias }))
|
|
105
|
+
} else if (keyword === 'hostname') {
|
|
106
|
+
for (const h of current) h.hostName = value
|
|
107
|
+
} else if (keyword === 'user') {
|
|
108
|
+
for (const h of current) h.user = value
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
flush()
|
|
112
|
+
|
|
113
|
+
// De-dupe by alias, preserving first occurrence (ssh's own precedence).
|
|
114
|
+
const seen = new Set<string>()
|
|
115
|
+
return hosts.filter((h) => (seen.has(h.alias) ? false : (seen.add(h.alias), true)))
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ─── Recent hosts (MRU) ────────────────────────────
|
|
119
|
+
|
|
120
|
+
const RECENTS_FILE = 'ssh-recents.json'
|
|
121
|
+
const MAX_RECENTS = 8
|
|
122
|
+
|
|
123
|
+
function recentsPath(settingsDir: string): string {
|
|
124
|
+
return path.join(settingsDir, RECENTS_FILE)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function getRecentHosts(settingsDir: string): string[] {
|
|
128
|
+
try {
|
|
129
|
+
const raw = JSON.parse(fs.readFileSync(recentsPath(settingsDir), 'utf-8'))
|
|
130
|
+
if (Array.isArray(raw)) return raw.filter((x) => typeof x === 'string')
|
|
131
|
+
} catch {
|
|
132
|
+
/* no recents yet */
|
|
133
|
+
}
|
|
134
|
+
return []
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Record `target` as most-recently-used (newest first, de-duped, capped). */
|
|
138
|
+
export function addRecentHost(settingsDir: string, target: string): void {
|
|
139
|
+
const t = target.trim()
|
|
140
|
+
if (!t) return
|
|
141
|
+
const next = [t, ...getRecentHosts(settingsDir).filter((x) => x !== t)].slice(0, MAX_RECENTS)
|
|
142
|
+
try {
|
|
143
|
+
fs.mkdirSync(settingsDir, { recursive: true })
|
|
144
|
+
fs.writeFileSync(recentsPath(settingsDir), JSON.stringify(next, null, 2))
|
|
145
|
+
} catch {
|
|
146
|
+
/* non-fatal: recents are a convenience */
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ─── Non-interactive probe ─────────────────────────
|
|
151
|
+
|
|
152
|
+
// Remote one-liner: prove tmux presence, then list sessions. Markers let us
|
|
153
|
+
// parse a single combined stdout stream regardless of shell chrome.
|
|
154
|
+
const PROBE_REMOTE_CMD =
|
|
155
|
+
'command -v tmux >/dev/null 2>&1 && echo __TMUX_OK__; tmux ls 2>/dev/null; echo __DONE__'
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Probe a remote host without any interaction. Uses BatchMode so it fails fast
|
|
159
|
+
* (rather than hanging on a password prompt) when key auth isn't available —
|
|
160
|
+
* that case is reported as `authRequired`, signalling the UI to use the
|
|
161
|
+
* interactive path. Only meaningful for key-authenticated hosts.
|
|
162
|
+
*/
|
|
163
|
+
export function probeRemote(target: string): Promise<ProbeResult> {
|
|
164
|
+
return new Promise((resolve) => {
|
|
165
|
+
const args = [
|
|
166
|
+
'-o', 'BatchMode=yes',
|
|
167
|
+
'-o', 'ConnectTimeout=8',
|
|
168
|
+
'-o', 'StrictHostKeyChecking=accept-new',
|
|
169
|
+
target,
|
|
170
|
+
PROBE_REMOTE_CMD,
|
|
171
|
+
]
|
|
172
|
+
let stdout = ''
|
|
173
|
+
let stderr = ''
|
|
174
|
+
let settled = false
|
|
175
|
+
const done = (r: ProbeResult) => {
|
|
176
|
+
if (settled) return
|
|
177
|
+
settled = true
|
|
178
|
+
resolve(r)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
let child: ReturnType<typeof spawn>
|
|
182
|
+
try {
|
|
183
|
+
child = spawn('ssh', args, { stdio: ['ignore', 'pipe', 'pipe'] })
|
|
184
|
+
} catch (err: any) {
|
|
185
|
+
done({ reachable: false, authRequired: false, error: err?.message || 'spawn failed', tmuxInstalled: false, sessions: [], ctlsurfSessions: [] })
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Hard ceiling in case ssh wedges despite ConnectTimeout.
|
|
190
|
+
const killTimer = setTimeout(() => {
|
|
191
|
+
try { child.kill() } catch { /* already gone */ }
|
|
192
|
+
done({ reachable: false, authRequired: false, error: 'Timed out connecting', tmuxInstalled: false, sessions: [], ctlsurfSessions: [] })
|
|
193
|
+
}, 12_000)
|
|
194
|
+
|
|
195
|
+
child.stdout?.on('data', (d) => { stdout += d.toString() })
|
|
196
|
+
child.stderr?.on('data', (d) => { stderr += d.toString() })
|
|
197
|
+
|
|
198
|
+
child.on('error', (err) => {
|
|
199
|
+
clearTimeout(killTimer)
|
|
200
|
+
done({ reachable: false, authRequired: false, error: err.message, tmuxInstalled: false, sessions: [], ctlsurfSessions: [] })
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
child.on('close', (code) => {
|
|
204
|
+
clearTimeout(killTimer)
|
|
205
|
+
if (!stdout.includes('__DONE__')) {
|
|
206
|
+
// The remote command never ran → couldn't connect/authenticate.
|
|
207
|
+
const authRequired = /permission denied|password|publickey/i.test(stderr)
|
|
208
|
+
const error = authRequired
|
|
209
|
+
? 'Key auth refused (password required)'
|
|
210
|
+
: (stderr.trim().split('\n').pop() || `ssh exited ${code}`)
|
|
211
|
+
done({ reachable: false, authRequired, error, tmuxInstalled: false, sessions: [], ctlsurfSessions: [] })
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
const tmuxInstalled = stdout.includes('__TMUX_OK__')
|
|
215
|
+
const sessions = parseTmuxSessions(stdout)
|
|
216
|
+
done({
|
|
217
|
+
reachable: true,
|
|
218
|
+
authRequired: false,
|
|
219
|
+
tmuxInstalled,
|
|
220
|
+
sessions,
|
|
221
|
+
ctlsurfSessions: sessions.filter((s) => s.startsWith(TMUX_SESSION_PREFIX)),
|
|
222
|
+
})
|
|
223
|
+
})
|
|
224
|
+
})
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ─── Password prompt detection (for one-shot injection) ────
|
|
228
|
+
|
|
229
|
+
/** Remove ANSI escape sequences so a colon-terminated prompt matches cleanly. */
|
|
230
|
+
export function stripAnsiSeq(s: string): string {
|
|
231
|
+
return s.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '')
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ssh's password/passphrase prompts, e.g. "user@host's password: " or
|
|
235
|
+
// "Enter passphrase for key '/path': ". Anchored to end-of-buffer so it only
|
|
236
|
+
// fires on the live prompt, not on the word appearing mid-output.
|
|
237
|
+
const PASSWORD_PROMPT_RE = /(?:password|passphrase)[^\n]*:\s*$/i
|
|
238
|
+
|
|
239
|
+
/** Whether the (ANSI-stripped) trailing output is sitting at a password prompt. */
|
|
240
|
+
export function isPasswordPrompt(tail: string): boolean {
|
|
241
|
+
return PASSWORD_PROMPT_RE.test(tail)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Extract session names from `tmux ls` output ("name: 1 windows ..." per line). */
|
|
245
|
+
export function parseTmuxSessions(stdout: string): string[] {
|
|
246
|
+
const out: string[] = []
|
|
247
|
+
for (const line of stdout.split('\n')) {
|
|
248
|
+
const t = line.trim()
|
|
249
|
+
if (!t || t.startsWith('__')) continue
|
|
250
|
+
const m = t.match(/^([^:\s]+):/)
|
|
251
|
+
if (m) out.push(m[1])
|
|
252
|
+
}
|
|
253
|
+
return out
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Lowest free `ctlsurf_N` (N >= 1) given the sessions already on the host. */
|
|
257
|
+
export function nextSessionName(existing: string[]): string {
|
|
258
|
+
const used = new Set(
|
|
259
|
+
existing
|
|
260
|
+
.filter((s) => s.startsWith(TMUX_SESSION_PREFIX))
|
|
261
|
+
.map((s) => parseInt(s.slice(TMUX_SESSION_PREFIX.length), 10))
|
|
262
|
+
.filter((n) => Number.isInteger(n) && n > 0),
|
|
263
|
+
)
|
|
264
|
+
let n = 1
|
|
265
|
+
while (used.has(n)) n++
|
|
266
|
+
return `${TMUX_SESSION_PREFIX}${n}`
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ─── Remote command builder ────────────────────────
|
|
270
|
+
|
|
271
|
+
export type RemoteAction =
|
|
272
|
+
/** Reattach an existing tmux session. */
|
|
273
|
+
| { kind: 'attach'; session: string }
|
|
274
|
+
/** Create (or attach, via -A) a tmux session running the agent. */
|
|
275
|
+
| { kind: 'new-tmux'; session: string }
|
|
276
|
+
/** Run the agent directly, no tmux (no persistence). */
|
|
277
|
+
| { kind: 'no-tmux' }
|
|
278
|
+
/** Password hosts: let the remote decide attach-or-create vs bare agent. */
|
|
279
|
+
| { kind: 'auto'; session: string }
|
|
280
|
+
|
|
281
|
+
export interface BuildRemoteOptions {
|
|
282
|
+
/** SSH target: a ~/.ssh/config alias or `user@host`. */
|
|
283
|
+
target: string
|
|
284
|
+
action: RemoteAction
|
|
285
|
+
/** Remote command to run as the agent. Empty string = remote login shell. */
|
|
286
|
+
agentCmd?: string
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Single-quote a string for safe embedding in a remote sh command.
|
|
290
|
+
function shQuote(s: string): string {
|
|
291
|
+
return `'${s.replace(/'/g, `'\\''`)}'`
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function remoteCommandFor(action: RemoteAction, agentCmd: string): string {
|
|
295
|
+
const inner = agentCmd.trim() // e.g. "claude"; empty → shell
|
|
296
|
+
switch (action.kind) {
|
|
297
|
+
case 'attach':
|
|
298
|
+
return `tmux attach -t ${shQuote(action.session)}`
|
|
299
|
+
case 'new-tmux':
|
|
300
|
+
// -A: attach if the session somehow already exists, else create it.
|
|
301
|
+
return inner
|
|
302
|
+
? `tmux new-session -A -s ${shQuote(action.session)} ${shQuote(inner)}`
|
|
303
|
+
: `tmux new-session -A -s ${shQuote(action.session)}`
|
|
304
|
+
case 'no-tmux':
|
|
305
|
+
return inner
|
|
306
|
+
case 'auto':
|
|
307
|
+
// Password host: probe + branch entirely on the remote so we never need
|
|
308
|
+
// a second (password-prompting) connection.
|
|
309
|
+
return inner
|
|
310
|
+
? `if command -v tmux >/dev/null 2>&1; then tmux new-session -A -s ${shQuote(action.session)} ${shQuote(inner)}; else ${inner}; fi`
|
|
311
|
+
: `if command -v tmux >/dev/null 2>&1; then tmux new-session -A -s ${shQuote(action.session)}; else exec "$SHELL" -l; fi`
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Build an AgentConfig that runs the chosen remote action via `ssh` in the PTY.
|
|
317
|
+
* The id is prefixed `remote-` so isRemoteAgent()/isCodingAgent() keep the tab
|
|
318
|
+
* out of local-only plumbing (transcripts, ctlsurf worker socket) while the
|
|
319
|
+
* always-on state monitor still drives the dock indicator.
|
|
320
|
+
*/
|
|
321
|
+
export function buildRemoteAgent(opts: BuildRemoteOptions): AgentConfig {
|
|
322
|
+
const { target, action } = opts
|
|
323
|
+
const agentCmd = opts.agentCmd ?? 'claude'
|
|
324
|
+
const remoteCmd = remoteCommandFor(action, agentCmd)
|
|
325
|
+
|
|
326
|
+
// `-t` forces a pseudo-terminal so the remote TUI (tmux/claude) renders.
|
|
327
|
+
const args = remoteCmd ? ['-t', target, remoteCmd] : ['-t', target]
|
|
328
|
+
|
|
329
|
+
const safeTarget = target.replace(/[^a-zA-Z0-9_.@-]/g, '_')
|
|
330
|
+
const label =
|
|
331
|
+
action.kind === 'attach' ? action.session
|
|
332
|
+
: action.kind === 'new-tmux' || action.kind === 'auto' ? action.session
|
|
333
|
+
: 'direct'
|
|
334
|
+
|
|
335
|
+
const agentLabel = agentCmd.trim() || 'shell'
|
|
336
|
+
const desc =
|
|
337
|
+
action.kind === 'attach' ? `SSH ${target} → attach tmux ${action.session}`
|
|
338
|
+
: action.kind === 'no-tmux' ? `SSH ${target} → ${agentLabel}`
|
|
339
|
+
: `SSH ${target} → tmux ${('session' in action) ? action.session : ''} → ${agentLabel}`
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
id: `remote-${safeTarget}-${label}`,
|
|
343
|
+
name: `${target} · ${label}`,
|
|
344
|
+
command: 'ssh',
|
|
345
|
+
args,
|
|
346
|
+
description: desc,
|
|
347
|
+
}
|
|
348
|
+
}
|
package/src/preload/index.ts
CHANGED
|
@@ -39,6 +39,32 @@ const api = {
|
|
|
39
39
|
getDefaultAgent: (): Promise<AgentConfig> =>
|
|
40
40
|
ipcRenderer.invoke('agents:default'),
|
|
41
41
|
|
|
42
|
+
// Remote (SSH)
|
|
43
|
+
sshListHosts: (): Promise<{
|
|
44
|
+
configHosts: Array<{ alias: string; hostName?: string; user?: string }>
|
|
45
|
+
recents: string[]
|
|
46
|
+
}> => ipcRenderer.invoke('ssh:listHosts'),
|
|
47
|
+
sshProbe: (target: string): Promise<{
|
|
48
|
+
reachable: boolean
|
|
49
|
+
authRequired: boolean
|
|
50
|
+
error?: string
|
|
51
|
+
tmuxInstalled: boolean
|
|
52
|
+
sessions: string[]
|
|
53
|
+
ctlsurfSessions: string[]
|
|
54
|
+
}> => ipcRenderer.invoke('ssh:probe', target),
|
|
55
|
+
sshBuildAgent: (target: string, action: unknown, agentCmd?: string): Promise<AgentConfig> =>
|
|
56
|
+
ipcRenderer.invoke('ssh:buildAgent', target, action, agentCmd),
|
|
57
|
+
sshAddRecent: (target: string): Promise<{ ok: boolean }> =>
|
|
58
|
+
ipcRenderer.invoke('ssh:addRecent', target),
|
|
59
|
+
sshSetPassword: (target: string, password: string): Promise<{ ok: boolean }> =>
|
|
60
|
+
ipcRenderer.invoke('ssh:setPassword', target, password),
|
|
61
|
+
|
|
62
|
+
// ctlsurf project selection (for remote sessions)
|
|
63
|
+
listProjects: (): Promise<{ ok: boolean; projects: Array<{ id: string; name: string; root_path: string }>; error?: string }> =>
|
|
64
|
+
ipcRenderer.invoke('ctlsurf:listProjects'),
|
|
65
|
+
bindProject: (rootPath: string): Promise<{ ok: boolean; error?: string }> =>
|
|
66
|
+
ipcRenderer.invoke('ctlsurf:bindProject', rootPath),
|
|
67
|
+
|
|
42
68
|
// App info
|
|
43
69
|
getHomePath: (): Promise<string> =>
|
|
44
70
|
ipcRenderer.invoke('app:homePath'),
|
package/src/renderer/App.tsx
CHANGED
|
@@ -5,6 +5,7 @@ import { SpeakControls } from './components/SpeakControls'
|
|
|
5
5
|
import { CtlsurfPanel } from './components/CtlsurfPanel'
|
|
6
6
|
import { EditorPanel } from './components/EditorPanel'
|
|
7
7
|
import { AgentPicker } from './components/AgentPicker'
|
|
8
|
+
import { ProjectPicker } from './components/ProjectPicker'
|
|
8
9
|
import {
|
|
9
10
|
PaneLayout,
|
|
10
11
|
type LayoutNode,
|
|
@@ -36,6 +37,13 @@ declare global {
|
|
|
36
37
|
onPtyExit: (callback: (tabId: string, code: number) => void) => () => void
|
|
37
38
|
listAgents: () => Promise<AgentConfig[]>
|
|
38
39
|
getDefaultAgent: () => Promise<AgentConfig>
|
|
40
|
+
sshListHosts: () => Promise<{ configHosts: Array<{ alias: string; hostName?: string; user?: string }>; recents: string[] }>
|
|
41
|
+
sshProbe: (target: string) => Promise<{ reachable: boolean; authRequired: boolean; error?: string; tmuxInstalled: boolean; sessions: string[]; ctlsurfSessions: string[] }>
|
|
42
|
+
sshBuildAgent: (target: string, action: unknown, agentCmd?: string) => Promise<AgentConfig>
|
|
43
|
+
sshAddRecent: (target: string) => Promise<{ ok: boolean }>
|
|
44
|
+
sshSetPassword: (target: string, password: string) => Promise<{ ok: boolean }>
|
|
45
|
+
listProjects: () => Promise<{ ok: boolean; projects: Array<{ id: string; name: string; root_path: string }>; error?: string }>
|
|
46
|
+
bindProject: (rootPath: string) => Promise<{ ok: boolean; error?: string }>
|
|
39
47
|
getHomePath: () => Promise<string>
|
|
40
48
|
getCwd: () => Promise<string>
|
|
41
49
|
getProjectName: () => Promise<string | null>
|
|
@@ -155,6 +163,9 @@ export default function App() {
|
|
|
155
163
|
// Agent picker state: which tab is being configured (null = initial picker for first tab)
|
|
156
164
|
const [pickerTargetTabId, setPickerTargetTabId] = useState<string | null>(tabs[0].id)
|
|
157
165
|
const [showAgentPicker, setShowAgentPicker] = useState(true)
|
|
166
|
+
// Project picker: shown right after a remote (SSH) session connects, to bind
|
|
167
|
+
// it to a ctlsurf project.
|
|
168
|
+
const [showProjectPicker, setShowProjectPicker] = useState(false)
|
|
158
169
|
|
|
159
170
|
// Track which panes are in the layout tree
|
|
160
171
|
const visiblePaneIds = findPaneIds(layout)
|
|
@@ -316,6 +327,11 @@ export default function App() {
|
|
|
316
327
|
}
|
|
317
328
|
setShowAgentPicker(false)
|
|
318
329
|
setPickerTargetTabId(null)
|
|
330
|
+
// Remote (SSH) sessions have no meaningful local cwd, so prompt the user to
|
|
331
|
+
// bind this session to a ctlsurf project once the terminal is up.
|
|
332
|
+
if (agent.id.startsWith('remote-')) {
|
|
333
|
+
setShowProjectPicker(true)
|
|
334
|
+
}
|
|
319
335
|
}, [pickerTargetTabId, tabs])
|
|
320
336
|
|
|
321
337
|
const closeTab = useCallback(async (tabId: string) => {
|
|
@@ -608,6 +624,10 @@ export default function App() {
|
|
|
608
624
|
/>
|
|
609
625
|
)}
|
|
610
626
|
|
|
627
|
+
{showProjectPicker && (
|
|
628
|
+
<ProjectPicker onDone={() => setShowProjectPicker(false)} />
|
|
629
|
+
)}
|
|
630
|
+
|
|
611
631
|
{showFloatingMic && (
|
|
612
632
|
<FloatingMic
|
|
613
633
|
onTranscript={handleVoiceTranscript}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react'
|
|
2
|
+
import { RemoteConnect } from './RemoteConnect'
|
|
2
3
|
|
|
3
4
|
interface AgentConfig {
|
|
4
5
|
id: string
|
|
@@ -19,6 +20,7 @@ export function AgentPicker({ agents, cwd, onSelect, onChangeCwd }: AgentPickerP
|
|
|
19
20
|
const displayPath = cwd.replace(/^\/Users\/[^/]+/, '~')
|
|
20
21
|
const [logChat, setLogChat] = useState(false)
|
|
21
22
|
const [loaded, setLoaded] = useState(false)
|
|
23
|
+
const [remoteMode, setRemoteMode] = useState(false)
|
|
22
24
|
|
|
23
25
|
useEffect(() => {
|
|
24
26
|
let cancelled = false
|
|
@@ -43,6 +45,15 @@ export function AgentPicker({ agents, cwd, onSelect, onChangeCwd }: AgentPickerP
|
|
|
43
45
|
onSelect(agent)
|
|
44
46
|
}
|
|
45
47
|
|
|
48
|
+
if (remoteMode) {
|
|
49
|
+
return (
|
|
50
|
+
<RemoteConnect
|
|
51
|
+
onConnect={(agent) => { handleSelect(agent) }}
|
|
52
|
+
onBack={() => setRemoteMode(false)}
|
|
53
|
+
/>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
46
57
|
return (
|
|
47
58
|
<div className="agent-picker-overlay">
|
|
48
59
|
<div className="agent-picker-modal">
|
|
@@ -66,6 +77,13 @@ export function AgentPicker({ agents, cwd, onSelect, onChangeCwd }: AgentPickerP
|
|
|
66
77
|
<span className="agent-picker-desc">{a.description}</span>
|
|
67
78
|
</button>
|
|
68
79
|
))}
|
|
80
|
+
<button
|
|
81
|
+
className="agent-picker-item"
|
|
82
|
+
onClick={() => setRemoteMode(true)}
|
|
83
|
+
>
|
|
84
|
+
<span className="agent-picker-name">🌐 Remote (SSH)</span>
|
|
85
|
+
<span className="agent-picker-desc">Run an agent on a remote machine</span>
|
|
86
|
+
</button>
|
|
69
87
|
</div>
|
|
70
88
|
|
|
71
89
|
<label className="agent-picker-option" onClick={(e) => e.stopPropagation()}>
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
interface Project {
|
|
4
|
+
id: string
|
|
5
|
+
name: string
|
|
6
|
+
root_path: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface ProjectPickerProps {
|
|
10
|
+
/** Called when the user picks a project or skips — the overlay should close. */
|
|
11
|
+
onDone: () => void
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type State = 'loading' | 'ready' | 'error'
|
|
15
|
+
|
|
16
|
+
// Shown right after a remote (SSH) session connects: lets the user bind it to a
|
|
17
|
+
// ctlsurf project so chat logging, tickets, and remote-control attach there.
|
|
18
|
+
export function ProjectPicker({ onDone }: ProjectPickerProps) {
|
|
19
|
+
const [state, setState] = useState<State>('loading')
|
|
20
|
+
const [projects, setProjects] = useState<Project[]>([])
|
|
21
|
+
const [errorMsg, setErrorMsg] = useState('')
|
|
22
|
+
const [binding, setBinding] = useState(false)
|
|
23
|
+
const [filter, setFilter] = useState('')
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
let cancelled = false
|
|
27
|
+
window.worker.listProjects().then((r) => {
|
|
28
|
+
if (cancelled) return
|
|
29
|
+
if (r.ok) {
|
|
30
|
+
setProjects(r.projects || [])
|
|
31
|
+
setState('ready')
|
|
32
|
+
} else {
|
|
33
|
+
setErrorMsg(r.error || 'Could not load projects')
|
|
34
|
+
setState('error')
|
|
35
|
+
}
|
|
36
|
+
}).catch((err) => {
|
|
37
|
+
if (cancelled) return
|
|
38
|
+
setErrorMsg(err?.message || 'Could not load projects')
|
|
39
|
+
setState('error')
|
|
40
|
+
})
|
|
41
|
+
return () => { cancelled = true }
|
|
42
|
+
}, [])
|
|
43
|
+
|
|
44
|
+
const pick = async (p: Project) => {
|
|
45
|
+
setBinding(true)
|
|
46
|
+
try {
|
|
47
|
+
await window.worker.bindProject(p.root_path)
|
|
48
|
+
} catch { /* non-fatal — session still runs unbound */ }
|
|
49
|
+
onDone()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const shown = filter.trim()
|
|
53
|
+
? projects.filter((p) =>
|
|
54
|
+
p.name.toLowerCase().includes(filter.toLowerCase()) ||
|
|
55
|
+
p.root_path.toLowerCase().includes(filter.toLowerCase()))
|
|
56
|
+
: projects
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<div className="agent-picker-overlay">
|
|
60
|
+
<div className="agent-picker-modal">
|
|
61
|
+
<div className="agent-picker-brand">ctlsurf</div>
|
|
62
|
+
<div className="agent-picker-title">SELECT CTLSURF PROJECT</div>
|
|
63
|
+
<div className="remote-target-line">Bind this remote session to a project</div>
|
|
64
|
+
|
|
65
|
+
{state === 'loading' && (
|
|
66
|
+
<div className="remote-status">
|
|
67
|
+
<div className="remote-spinner" />
|
|
68
|
+
<div>Loading projects…</div>
|
|
69
|
+
</div>
|
|
70
|
+
)}
|
|
71
|
+
|
|
72
|
+
{state === 'error' && (
|
|
73
|
+
<>
|
|
74
|
+
<div className="remote-note">{errorMsg}</div>
|
|
75
|
+
</>
|
|
76
|
+
)}
|
|
77
|
+
|
|
78
|
+
{state === 'ready' && (
|
|
79
|
+
<>
|
|
80
|
+
{projects.length === 0 && (
|
|
81
|
+
<div className="remote-note">No ctlsurf projects found. You can skip and bind one later.</div>
|
|
82
|
+
)}
|
|
83
|
+
|
|
84
|
+
{projects.length > 6 && (
|
|
85
|
+
<input
|
|
86
|
+
className="remote-input"
|
|
87
|
+
style={{ width: '100%', boxSizing: 'border-box', margin: '4px 0 8px' }}
|
|
88
|
+
type="text"
|
|
89
|
+
placeholder="Filter projects…"
|
|
90
|
+
value={filter}
|
|
91
|
+
onChange={(e) => setFilter(e.target.value)}
|
|
92
|
+
autoCapitalize="off"
|
|
93
|
+
autoCorrect="off"
|
|
94
|
+
spellCheck={false}
|
|
95
|
+
/>
|
|
96
|
+
)}
|
|
97
|
+
|
|
98
|
+
<div className="agent-picker-list remote-host-scroll">
|
|
99
|
+
{shown.map((p) => (
|
|
100
|
+
<button
|
|
101
|
+
key={p.id}
|
|
102
|
+
className="agent-picker-item project-item"
|
|
103
|
+
disabled={binding}
|
|
104
|
+
onClick={() => pick(p)}
|
|
105
|
+
title={p.root_path}
|
|
106
|
+
>
|
|
107
|
+
<span className="agent-picker-name">{p.name}</span>
|
|
108
|
+
<span className="agent-picker-desc">{p.root_path}</span>
|
|
109
|
+
</button>
|
|
110
|
+
))}
|
|
111
|
+
</div>
|
|
112
|
+
</>
|
|
113
|
+
)}
|
|
114
|
+
|
|
115
|
+
<button className="remote-link" disabled={binding} onClick={onDone}>
|
|
116
|
+
Skip — no project
|
|
117
|
+
</button>
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
)
|
|
121
|
+
}
|