@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.
Files changed (36) hide show
  1. package/out/headless/index.mjs +111 -5
  2. package/out/headless/index.mjs.map +3 -3
  3. package/out/main/index.js +357 -28
  4. package/out/preload/index.js +9 -0
  5. package/out/renderer/assets/{cssMode-BbCVOTaJ.js → cssMode-R8fTFLae.js} +3 -3
  6. package/out/renderer/assets/{freemarker2-DryLywQv.js → freemarker2-CyjWzWoP.js} +1 -1
  7. package/out/renderer/assets/{handlebars-CdUMtSdr.js → handlebars-CrhCGIRM.js} +1 -1
  8. package/out/renderer/assets/{html-Cwkf2GMH.js → html-Cc3dPNZr.js} +1 -1
  9. package/out/renderer/assets/{htmlMode-C7PzhXxH.js → htmlMode-BWtG6ywk.js} +3 -3
  10. package/out/renderer/assets/{index-BIceGcz3.css → index-BL9njXIL.css} +206 -46
  11. package/out/renderer/assets/{index-Dg1vMspf.js → index-kkG56aKv.js} +529 -156
  12. package/out/renderer/assets/{javascript-Cy7ROXJl.js → javascript-C8T_SdLC.js} +2 -2
  13. package/out/renderer/assets/{jsonMode-BtZtt3QW.js → jsonMode-D1zR4VVU.js} +3 -3
  14. package/out/renderer/assets/{liquid-xReAQeYs.js → liquid-COSMe9Uf.js} +1 -1
  15. package/out/renderer/assets/{lspLanguageFeatures-Dmx0L0Bw.js → lspLanguageFeatures-CXGfrtzl.js} +1 -1
  16. package/out/renderer/assets/{mdx-YMNFO1Zz.js → mdx-RfS44T-L.js} +1 -1
  17. package/out/renderer/assets/{python-BExaDVxv.js → python-DFSWKHUu.js} +1 -1
  18. package/out/renderer/assets/{razor-HL8ANmQ4.js → razor-DtH2_uvo.js} +1 -1
  19. package/out/renderer/assets/{tsMode-Dqd8vdLE.js → tsMode-BYIQ-S9Z.js} +1 -1
  20. package/out/renderer/assets/{typescript-Dysz1PTX.js → typescript-HhQUr1Rb.js} +1 -1
  21. package/out/renderer/assets/{xml-BHp5Z9nc.js → xml-KuhgdKfD.js} +1 -1
  22. package/out/renderer/assets/{yaml-CPDHxVup.js → yaml-W_kzCFH7.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 +118 -34
  28. package/src/main/orchestrator.ts +105 -4
  29. package/src/main/ssh.ts +348 -0
  30. package/src/preload/index.ts +26 -0
  31. package/src/renderer/App.tsx +29 -7
  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/TicketPanel.tsx +20 -25
  36. package/src/renderer/styles.css +206 -46
@@ -1,5 +1,5 @@
1
- import { conf as conf$1, language as language$1 } from "./typescript-Dysz1PTX.js";
2
- import "./index-Dg1vMspf.js";
1
+ import { conf as conf$1, language as language$1 } from "./typescript-HhQUr1Rb.js";
2
+ import "./index-kkG56aKv.js";
3
3
  const conf = conf$1;
4
4
  const language = {
5
5
  // Set defaultToken to invalid to see what you do not tokenize yet
@@ -1,6 +1,6 @@
1
- import { c as createWebWorker, l as languages, e as editor } from "./index-Dg1vMspf.js";
2
- import { f as DocumentFormattingEditProvider, g as DocumentRangeFormattingEditProvider, C as CompletionAdapter, H as HoverAdapter, b as DocumentSymbolAdapter, d as DocumentColorAdapter, F as FoldingRangeAdapter, S as SelectionRangeAdapter, e as DiagnosticsAdapter } from "./lspLanguageFeatures-Dmx0L0Bw.js";
3
- import { a, D, h, R, c, i, j, t, k } from "./lspLanguageFeatures-Dmx0L0Bw.js";
1
+ import { c as createWebWorker, l as languages, e as editor } from "./index-kkG56aKv.js";
2
+ import { f as DocumentFormattingEditProvider, g as DocumentRangeFormattingEditProvider, C as CompletionAdapter, H as HoverAdapter, b as DocumentSymbolAdapter, d as DocumentColorAdapter, F as FoldingRangeAdapter, S as SelectionRangeAdapter, e as DiagnosticsAdapter } from "./lspLanguageFeatures-CXGfrtzl.js";
3
+ import { a, D, h, R, c, 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,4 +1,4 @@
1
- import { l as languages } from "./index-Dg1vMspf.js";
1
+ import { l as languages } from "./index-kkG56aKv.js";
2
2
  const EMPTY_ELEMENTS = [
3
3
  "area",
4
4
  "base",
@@ -1,4 +1,4 @@
1
- import { R as Range$1, l as languages, e as editor, U as Uri, M as MarkerSeverity } from "./index-Dg1vMspf.js";
1
+ import { R as Range$1, l as languages, e as editor, U as Uri, M as MarkerSeverity } from "./index-kkG56aKv.js";
2
2
  var DocumentUri;
3
3
  (function(DocumentUri2) {
4
4
  function is(value) {
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-Dg1vMspf.js";
1
+ import { l as languages } from "./index-kkG56aKv.js";
2
2
  const conf = {
3
3
  comments: {
4
4
  blockComment: ["{/*", "*/}"]
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-Dg1vMspf.js";
1
+ import { l as languages } from "./index-kkG56aKv.js";
2
2
  const conf = {
3
3
  comments: {
4
4
  lineComment: "#",
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-Dg1vMspf.js";
1
+ import { l as languages } from "./index-kkG56aKv.js";
2
2
  const EMPTY_ELEMENTS = [
3
3
  "area",
4
4
  "base",
@@ -1,4 +1,4 @@
1
- import { c as createWebWorker, e as editor, U as Uri, a as MarkerTag, M as MarkerSeverity, l as languages, t as typescriptDefaults, R as Range } from "./index-Dg1vMspf.js";
1
+ import { c as createWebWorker, e as editor, U as Uri, a as MarkerTag, M as MarkerSeverity, l as languages, t as typescriptDefaults, R as Range } from "./index-kkG56aKv.js";
2
2
  class WorkerManager {
3
3
  constructor(_modeId, _defaults) {
4
4
  this._modeId = _modeId;
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-Dg1vMspf.js";
1
+ import { l as languages } from "./index-kkG56aKv.js";
2
2
  const conf = {
3
3
  wordPattern: /(-?\d*\.\d\w*)|([^\`\~\!\@\#\%\^\&\*\(\)\-\=\+\[\{\]\}\\\|\;\:\'\"\,\.\<\>\/\?\s]+)/g,
4
4
  comments: {
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-Dg1vMspf.js";
1
+ import { l as languages } from "./index-kkG56aKv.js";
2
2
  const conf = {
3
3
  comments: {
4
4
  blockComment: ["<!--", "-->"]
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-Dg1vMspf.js";
1
+ import { l as languages } from "./index-kkG56aKv.js";
2
2
  const conf = {
3
3
  comments: {
4
4
  lineComment: "#"
@@ -49,8 +49,8 @@
49
49
  color: #565f89;
50
50
  }
51
51
  </style>
52
- <script type="module" crossorigin src="./assets/index-Dg1vMspf.js"></script>
53
- <link rel="stylesheet" crossorigin href="./assets/index-BIceGcz3.css">
52
+ <script type="module" crossorigin src="./assets/index-kkG56aKv.js"></script>
53
+ <link rel="stylesheet" crossorigin href="./assets/index-BL9njXIL.css">
54
54
  </head>
55
55
  <body>
56
56
  <div id="boot-splash">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phenx-inc/ctlsurf",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "Agent-agnostic terminal and desktop app for ctlsurf — run Claude Code, Codex, or any coding agent with live session logging and remote control",
5
5
  "main": "out/main/index.js",
6
6
  "bin": {
@@ -76,6 +76,14 @@ export function getDefaultAgent(): AgentConfig {
76
76
  return getAvailableAgents()[0]
77
77
  }
78
78
 
79
+ // A remote agent runs over SSH (see ssh.ts). Its byte stream still flows
80
+ // through the PTY — so the always-on agent-state monitor drives the dock
81
+ // indicator — but its session files live on the remote box, so it must stay out
82
+ // of local-only plumbing (transcript tailing, the ctlsurf worker socket).
83
+ export function isRemoteAgent(agent: AgentConfig): boolean {
84
+ return agent.id.startsWith('remote-')
85
+ }
86
+
79
87
  export function isCodingAgent(agent: AgentConfig): boolean {
80
- return agent.id !== 'shell'
88
+ return agent.id !== 'shell' && !isRemoteAgent(agent)
81
89
  }
@@ -90,6 +90,13 @@ export class CtlsurfApi {
90
90
  return this.request('POST', '/folders', params)
91
91
  }
92
92
 
93
+ // All folders (projects) the API key can see. Used to let a remote session
94
+ // pick which ctlsurf project to bind to.
95
+ async listFolders(): Promise<Array<{ id: string; name: string; root_path?: string; git_remote?: string }>> {
96
+ const folders = await this.request('GET', '/folders')
97
+ return Array.isArray(folders) ? folders : []
98
+ }
99
+
93
100
  // ─── Workers ────────────────────────────────────────
94
101
 
95
102
  async getAuthCode(): Promise<{ code: string }> {
package/src/main/index.ts CHANGED
@@ -25,6 +25,9 @@ import { AgentConfig, getDefaultAgent, getAvailableAgents } from './agents'
25
25
  import { getSettingsDir } from './settingsDir'
26
26
  import { Orchestrator } from './orchestrator'
27
27
  import { AttentionController } from './agentState'
28
+ import { listSshHosts, getRecentHosts, addRecentHost, probeRemote, buildRemoteAgent, type RemoteAction } from './ssh'
29
+
30
+ const SETTINGS_DIR = getSettingsDir(true)
28
31
 
29
32
  let mainWindow: BrowserWindow | null = null
30
33
 
@@ -51,12 +54,12 @@ function getProjectAbbrev(cwdPath: string): string {
51
54
  return word.slice(0, 2).toUpperCase()
52
55
  }
53
56
 
54
- // Draw a 32x32 filled circle overlay. `red` switches between the project-badge
55
- // blue and the "awaiting input" red dot.
56
- function createDotOverlay(red: boolean): Electron.NativeImage {
57
+ // Draw a 32x32 filled circle overlay (Windows taskbar). `ready` switches between
58
+ // the project-badge blue and the green "ready for you" dot.
59
+ function createDotOverlay(ready: boolean): Electron.NativeImage {
57
60
  const size = 32
58
61
  const canvas = Buffer.alloc(size * size * 4, 0) // RGBA
59
- const [r, g, b] = red ? [235, 64, 52] : [122, 162, 247]
62
+ const [r, g, b] = ready ? [52, 199, 89] : [122, 162, 247]
60
63
  for (let y = 0; y < size; y++) {
61
64
  for (let x = 0; x < size; x++) {
62
65
  const cx = x - size / 2
@@ -73,6 +76,43 @@ function createDotOverlay(red: boolean): Electron.NativeImage {
73
76
  return nativeImage.createFromBuffer(canvas, { width: size, height: size })
74
77
  }
75
78
 
79
+ // The plain dock icon (macOS), loaded once at startup. Reused to composite the
80
+ // green "ready" dot on top, and to restore the clean icon when it clears.
81
+ let baseDockIcon: Electron.NativeImage | null = null
82
+
83
+ // Composite a green "ready for you" dot (with a soft dark ring for contrast) into
84
+ // the lower-right of the dock icon. Reads/writes the bitmap as a matched
85
+ // toBitmap()/createFromBitmap() pair, which share the same (BGRA) byte order.
86
+ function dockIconWithReadyDot(base: Electron.NativeImage): Electron.NativeImage {
87
+ const { width, height } = base.getSize()
88
+ if (!width || !height) return base
89
+ const bmp = base.toBitmap()
90
+ const r = Math.round(Math.min(width, height) * 0.17) // dot radius
91
+ const ring = Math.max(2, Math.round(r * 0.22)) // ring thickness
92
+ const cx = width - r - Math.round(width * 0.05)
93
+ const cy = height - r - Math.round(height * 0.05)
94
+ const put = (x: number, y: number, b: number, g: number, rr: number) => {
95
+ const idx = (y * width + x) * 4
96
+ bmp[idx] = b; bmp[idx + 1] = g; bmp[idx + 2] = rr; bmp[idx + 3] = 255
97
+ }
98
+ for (let y = cy - r - ring; y <= cy + r + ring; y++) {
99
+ if (y < 0 || y >= height) continue
100
+ for (let x = cx - r - ring; x <= cx + r + ring; x++) {
101
+ if (x < 0 || x >= width) continue
102
+ const d = Math.sqrt((x - cx) ** 2 + (y - cy) ** 2)
103
+ if (d <= r) put(x, y, 89, 199, 52) // green #34c759 (BGRA)
104
+ else if (d <= r + ring) put(x, y, 24, 26, 31) // dark ring #1f1a18
105
+ }
106
+ }
107
+ return nativeImage.createFromBitmap(bmp, { width, height })
108
+ }
109
+
110
+ // Show/clear the green "ready" dot on the macOS dock icon.
111
+ function setDockReadyDot(on: boolean): void {
112
+ if (process.platform !== 'darwin' || !app.dock || !baseDockIcon) return
113
+ app.dock.setIcon(on ? dockIconWithReadyDot(baseDockIcon) : baseDockIcon)
114
+ }
115
+
76
116
  // Last project abbreviation, so the steady badge can be restored after the
77
117
  // "awaiting input" indicator clears.
78
118
  let projectAbbrev = ''
@@ -90,8 +130,9 @@ function updateProjectBadge(cwdPath: string | null): void {
90
130
  if (!cwdPath) return
91
131
  projectAbbrev = getProjectAbbrev(cwdPath)
92
132
 
93
- // Don't stomp the awaiting-input blink; it restores the steady badge itself.
94
- if (!blinkTimer) applySteadyBadge()
133
+ // The project badge and the "ready" dot are now independent surfaces (badge
134
+ // vs dock icon), so the badge can always update.
135
+ applySteadyBadge()
95
136
 
96
137
  // All platforms: update window title
97
138
  if (mainWindow) {
@@ -100,18 +141,17 @@ function updateProjectBadge(cwdPath: string | null): void {
100
141
  }
101
142
  }
102
143
 
103
- // ─── "Awaiting input" attention indicator (cross-platform) ──────────
144
+ // ─── "Ready for you" attention indicator (cross-platform) ──────────
104
145
  //
105
146
  // The detection is OS-independent; only the visual differs:
106
- // macOS — blink a red dot in the dock badge + one bounce
107
- // Windows — red overlay icon on the taskbar button + flash the button
147
+ // macOS — steady green dot composited on the dock icon + one bounce
148
+ // Windows — green overlay icon on the taskbar button + flash the button
108
149
  // Linux — badge count where supported + flash (window urgency hint)
109
150
  // While the window is focused the dock/taskbar nag is suppressed (the in-app
110
151
  // dot is enough); the renderer always gets `agent:state` regardless of OS.
111
152
 
112
153
  const attention = new AttentionController()
113
- let blinkTimer: ReturnType<typeof setInterval> | null = null
114
- let blinkOn = false
154
+ let attentionActive = false
115
155
  let bounceId = -1
116
156
 
117
157
  function setAwaitingIndicator(on: boolean): void {
@@ -124,40 +164,31 @@ function applyAttention(active: boolean): void {
124
164
  }
125
165
 
126
166
  function startAttention(): void {
127
- if (blinkTimer) return
128
- log('[agent-state] attention START (blink + bounce)')
167
+ if (attentionActive) return
168
+ attentionActive = true
169
+ log('[agent-state] attention START (green ready dot + bounce)')
129
170
 
130
171
  if (process.platform === 'darwin' && app.dock) {
172
+ setDockReadyDot(true)
131
173
  bounceId = app.dock.bounce('informational')
132
174
  }
133
175
  if (process.platform === 'win32' && mainWindow) {
134
- mainWindow.setOverlayIcon(createDotOverlay(true), 'Awaiting input')
176
+ mainWindow.setOverlayIcon(createDotOverlay(true), 'Ready for you')
135
177
  mainWindow.flashFrame(true)
136
178
  }
137
179
  if (process.platform === 'linux') {
138
180
  if (typeof app.badgeCount === 'number') app.badgeCount = 1
139
181
  mainWindow?.flashFrame(true)
140
182
  }
141
-
142
- // Blink the macOS dock badge — the whole red pill (project initials and all)
143
- // toggles on/off, so the project letters are preserved on the "on" frames
144
- // rather than being replaced by a dot.
145
- blinkOn = true
146
- blinkTimer = setInterval(() => {
147
- blinkOn = !blinkOn
148
- if (process.platform === 'darwin' && app.dock) {
149
- app.dock.setBadge(blinkOn ? (projectAbbrev || '●') : '')
150
- }
151
- }, 600)
152
183
  }
153
184
 
154
185
  function stopAttention(): void {
155
- if (blinkTimer) {
156
- clearInterval(blinkTimer)
157
- blinkTimer = null
158
- log('[agent-state] attention STOP')
159
- }
186
+ if (!attentionActive) return
187
+ attentionActive = false
188
+ log('[agent-state] attention STOP')
189
+
160
190
  if (process.platform === 'darwin' && app.dock) {
191
+ setDockReadyDot(false)
161
192
  if (bounceId >= 0) { app.dock.cancelBounce(bounceId); bounceId = -1 }
162
193
  }
163
194
  if (process.platform === 'win32' && mainWindow) {
@@ -173,7 +204,7 @@ function stopAttention(): void {
173
204
  // ─── Orchestrator (shared logic) ──────────────────
174
205
 
175
206
  const orchestrator = new Orchestrator(
176
- getSettingsDir(true),
207
+ SETTINGS_DIR,
177
208
  {
178
209
  onPtyData: (tabId, data) => mainWindow?.webContents.send('pty:data', tabId, data),
179
210
  onPtyExit: (tabId, code) => mainWindow?.webContents.send('pty:exit', tabId, code),
@@ -270,6 +301,56 @@ ipcMain.handle('pty:setActiveTab', (_event, tabId: string) => {
270
301
  ipcMain.handle('agents:list', () => getAvailableAgents())
271
302
  ipcMain.handle('agents:default', () => getDefaultAgent())
272
303
 
304
+ // ─── Remote (SSH) ─────────────────────────────────
305
+
306
+ // Hosts to offer: recently-used targets first, then ~/.ssh/config aliases.
307
+ ipcMain.handle('ssh:listHosts', () => ({
308
+ configHosts: listSshHosts(),
309
+ recents: getRecentHosts(SETTINGS_DIR),
310
+ }))
311
+
312
+ // Non-interactive reachability + tmux probe (key-auth hosts only).
313
+ ipcMain.handle('ssh:probe', (_event, target: string) => probeRemote(target))
314
+
315
+ // Build the ssh-backed AgentConfig for a chosen connect action.
316
+ ipcMain.handle('ssh:buildAgent', (_event, target: string, action: RemoteAction, agentCmd?: string) =>
317
+ buildRemoteAgent({ target, action, agentCmd }),
318
+ )
319
+
320
+ // Remember a target as most-recently-used (no secrets stored — just the host).
321
+ ipcMain.handle('ssh:addRecent', (_event, target: string) => {
322
+ addRecentHost(SETTINGS_DIR, target)
323
+ return { ok: true }
324
+ })
325
+
326
+ // Stash an in-app password for the next remote spawn to `target`. Held only in
327
+ // memory (auto-expires), injected once into ssh's prompt, then dropped.
328
+ ipcMain.handle('ssh:setPassword', (_event, target: string, password: string) => {
329
+ orchestrator.setPendingSshPassword(target, password)
330
+ return { ok: true }
331
+ })
332
+
333
+ // ─── ctlsurf project selection (for remote sessions) ──
334
+
335
+ // List existing ctlsurf projects (folders) to pick from.
336
+ ipcMain.handle('ctlsurf:listProjects', async () => {
337
+ if (!orchestrator.ctlsurfApi.getApiKey()) return { ok: false, projects: [], error: 'No ctlsurf API key configured' }
338
+ try {
339
+ const folders = await orchestrator.ctlsurfApi.listFolders()
340
+ const projects = folders
341
+ .filter((f) => f && f.id && f.root_path)
342
+ .map((f) => ({ id: f.id, name: f.name || '(unnamed)', root_path: f.root_path as string }))
343
+ return { ok: true, projects }
344
+ } catch (err: any) {
345
+ return { ok: false, projects: [], error: err?.message || 'Failed to list projects' }
346
+ }
347
+ })
348
+
349
+ // Bind the active (remote) tab to a chosen project by its root_path.
350
+ ipcMain.handle('ctlsurf:bindProject', (_event, rootPath: string) =>
351
+ orchestrator.bindActiveTabToProject(rootPath),
352
+ )
353
+
273
354
  ipcMain.handle('app:homePath', () => app.getPath('home'))
274
355
  ipcMain.handle('app:cwd', () => process.env.CTLSURF_WORKER_CWD || process.cwd())
275
356
  ipcMain.handle('app:projectName', () => orchestrator.projectName)
@@ -414,7 +495,8 @@ ipcMain.handle('worker:getWebviewInfo', async () => {
414
495
  try {
415
496
  const { code } = await orchestrator.ctlsurfApi.getAuthCode()
416
497
  let pageUrl = `${frontendUrl}?embed=1&_code=${encodeURIComponent(code)}`
417
- const currentCwd = orchestrator.cwd
498
+ // Prefer the active tab's bound project (remote sessions), else the local cwd.
499
+ const currentCwd = orchestrator.getActiveTabProjectPath() || orchestrator.cwd
418
500
 
419
501
  if (currentCwd) {
420
502
  try {
@@ -520,7 +602,8 @@ ipcMain.handle('tracking:set', async (_event, enabled: boolean) => {
520
602
  // ─── Tickets IPC ──────────────────────────────────
521
603
 
522
604
  ipcMain.handle('tickets:project', () => {
523
- const cwd = orchestrator.getActiveTabCwd()
605
+ // Use the bound project path for remote sessions, else the local cwd.
606
+ const cwd = orchestrator.getActiveTabProjectPath()
524
607
  return { cwd, name: cwd ? cwd.split('/').filter(Boolean).pop() || cwd : null }
525
608
  })
526
609
  ipcMain.handle('tickets:add', async (_event, input: {
@@ -582,7 +665,8 @@ app.whenReady().then(() => {
582
665
  if (process.platform === 'darwin' && app.dock) {
583
666
  const iconPath = path.join(__dirname, '../../resources/icon.png')
584
667
  try {
585
- app.dock.setIcon(nativeImage.createFromPath(iconPath))
668
+ baseDockIcon = nativeImage.createFromPath(iconPath)
669
+ app.dock.setIcon(baseDockIcon)
586
670
  } catch { /* ignore */ }
587
671
  }
588
672
 
@@ -3,7 +3,8 @@ import fs from 'fs'
3
3
  import os from 'os'
4
4
 
5
5
  import { PtyManager } from './pty'
6
- import { AgentConfig, isCodingAgent } from './agents'
6
+ import { AgentConfig, isCodingAgent, isRemoteAgent } from './agents'
7
+ import { stripAnsiSeq, isPasswordPrompt } from './ssh'
7
8
  import { CtlsurfApi } from './ctlsurfApi'
8
9
  import { ConversationBridge } from './bridge'
9
10
  import { TranscriptTailer, supportsTranscriptLogging, readLatestAssistantReply } from './transcripts'
@@ -62,6 +63,10 @@ interface TabState {
62
63
  ptyManager: PtyManager
63
64
  agent: AgentConfig
64
65
  cwd: string
66
+ // For remote sessions bound to a ctlsurf project: the project's root_path,
67
+ // used in place of the (meaningless) local cwd for project-scoped work like
68
+ // tickets. Set by bindActiveTabToProject.
69
+ projectRootPath?: string
65
70
  termStreamBuffer: string
66
71
  termStreamTimer: ReturnType<typeof setTimeout> | null
67
72
  }
@@ -108,6 +113,10 @@ export class Orchestrator {
108
113
  private currentProjectName: string | null = null
109
114
  private transcriptTailer: TranscriptTailer | null = null
110
115
  private readonly stateMonitor: AgentStateMonitor
116
+ // One-shot SSH passwords entered in-app, keyed by target. Held only in memory
117
+ // until the next remote spawn for that target consumes them — never persisted.
118
+ private readonly pendingSshPasswords = new Map<string, string>()
119
+ private readonly pendingSshPasswordTimers = new Map<string, ReturnType<typeof setTimeout>>()
111
120
 
112
121
  constructor(settingsDir: string, events: OrchestratorEvents) {
113
122
  this.settingsDir = settingsDir
@@ -455,6 +464,50 @@ export class Orchestrator {
455
464
 
456
465
  // ─── PTY & Agent (multi-tab) ─────────────────────
457
466
 
467
+ // Stash an in-app SSH password for the next remote spawn to `target`. Kept
468
+ // only in memory and auto-expired so a typed-but-unused password doesn't
469
+ // linger. Never written to disk or logged.
470
+ setPendingSshPassword(target: string, password: string): void {
471
+ if (!target || !password) return
472
+ const prev = this.pendingSshPasswordTimers.get(target)
473
+ if (prev) clearTimeout(prev)
474
+ this.pendingSshPasswords.set(target, password)
475
+ this.pendingSshPasswordTimers.set(target, setTimeout(() => {
476
+ this.pendingSshPasswords.delete(target)
477
+ this.pendingSshPasswordTimers.delete(target)
478
+ }, 120_000))
479
+ }
480
+
481
+ private consumePendingSshPassword(target: string): string | undefined {
482
+ const pw = this.pendingSshPasswords.get(target)
483
+ if (pw === undefined) return undefined
484
+ this.pendingSshPasswords.delete(target)
485
+ const timer = this.pendingSshPasswordTimers.get(target)
486
+ if (timer) { clearTimeout(timer); this.pendingSshPasswordTimers.delete(target) }
487
+ return pw
488
+ }
489
+
490
+ // Watch a freshly-spawned ssh PTY for its password/passphrase prompt and write
491
+ // the password once. We write straight to the PTY (not through writePty) so it
492
+ // never reaches the bridge/chat-logging path; ssh keeps echo off, so it never
493
+ // renders to the terminal or scrollback either.
494
+ private armPasswordInjector(ptyManager: PtyManager, password: string): void {
495
+ let pw: string | null = password
496
+ let tail = ''
497
+ const giveUp = setTimeout(() => { pw = null; tail = '' }, 30_000)
498
+ ptyManager.onData((data: string) => {
499
+ if (pw === null) return
500
+ tail = (tail + stripAnsiSeq(data)).slice(-200)
501
+ if (isPasswordPrompt(tail)) {
502
+ log('[ssh] password prompt detected — injecting in-app password (one-shot)')
503
+ ptyManager.write(pw + '\r')
504
+ pw = null
505
+ tail = ''
506
+ clearTimeout(giveUp)
507
+ }
508
+ })
509
+ }
510
+
458
511
  async spawnAgent(tabId: string, agent: AgentConfig, cwd: string, opts?: { trackTime?: boolean }): Promise<void> {
459
512
  // Kill existing PTY on this tab if any
460
513
  const existing = this.tabs.get(tabId)
@@ -479,6 +532,18 @@ export class Orchestrator {
479
532
  // exactly when the dock indicator is most useful.
480
533
  this.stateMonitor.trackTab(tabId, agent.id, cwd)
481
534
 
535
+ // Remote (SSH) tab with an in-app password waiting: feed it to ssh's prompt
536
+ // once, directly over the PTY. The password is consumed here (removed from
537
+ // the pending map) and dropped after injection — never logged or persisted.
538
+ if (isRemoteAgent(agent)) {
539
+ const target = agent.args[1] // args are ['-t', target, ...]
540
+ const pw = target ? this.consumePendingSshPassword(target) : undefined
541
+ if (pw) {
542
+ log(`[ssh] remote spawn for ${target}: in-app password armed (will inject at prompt)`)
543
+ this.armPasswordInjector(ptyManager, pw)
544
+ }
545
+ }
546
+
482
547
  ptyManager.onData((data: string) => {
483
548
  this.events.onPtyData(tabId, data)
484
549
  this.timeTracker.recordActivity(tabId)
@@ -607,8 +672,19 @@ export class Orchestrator {
607
672
  return this.tabs.get(this.activeTabId)?.cwd ?? null
608
673
  }
609
674
 
675
+ /**
676
+ * Path used to resolve the active tab's ctlsurf project. For a remote session
677
+ * bound to a project this is the project's root_path; otherwise the local cwd.
678
+ */
679
+ getActiveTabProjectPath(): string | null {
680
+ if (!this.activeTabId) return null
681
+ const tab = this.tabs.get(this.activeTabId)
682
+ if (!tab) return null
683
+ return tab.projectRootPath ?? tab.cwd
684
+ }
685
+
610
686
  async addTicketForActiveTab(input: TicketInput): Promise<{ ok: boolean; error?: string }> {
611
- const cwd = this.getActiveTabCwd()
687
+ const cwd = this.getActiveTabProjectPath()
612
688
  if (!cwd) return { ok: false, error: 'No active terminal tab' }
613
689
  if (!this.ctlsurfApi.getApiKey()) {
614
690
  return { ok: false, error: 'ctlsurf API key not configured' }
@@ -617,7 +693,7 @@ export class Orchestrator {
617
693
  }
618
694
 
619
695
  async updateTicketForActiveTab(rowId: string, input: TicketInput): Promise<{ ok: boolean; error?: string }> {
620
- const cwd = this.getActiveTabCwd()
696
+ const cwd = this.getActiveTabProjectPath()
621
697
  if (!cwd) return { ok: false, error: 'No active terminal tab' }
622
698
  if (!this.ctlsurfApi.getApiKey()) {
623
699
  return { ok: false, error: 'ctlsurf API key not configured' }
@@ -626,7 +702,7 @@ export class Orchestrator {
626
702
  }
627
703
 
628
704
  async listTicketsForActiveTab(): Promise<{ ok: boolean; tickets: import('./ticketStore').Ticket[]; error?: string }> {
629
- const cwd = this.getActiveTabCwd()
705
+ const cwd = this.getActiveTabProjectPath()
630
706
  if (!cwd) return { ok: false, tickets: [], error: 'No active terminal tab' }
631
707
  if (!this.ctlsurfApi.getApiKey()) {
632
708
  return { ok: false, tickets: [], error: 'ctlsurf API key not configured' }
@@ -636,6 +712,31 @@ export class Orchestrator {
636
712
 
637
713
  // ─── Worker WebSocket ───────────────────────────
638
714
 
715
+ // Bind the active tab (typically a remote SSH session, whose local cwd is
716
+ // meaningless) to a chosen ctlsurf project. We register the worker WS using
717
+ // the project's root_path so the backend resolves that folder — the same
718
+ // mechanism create-project relies on. This lights up chat logging, tickets,
719
+ // remote-control, and the project header/panel for the remote session.
720
+ bindActiveTabToProject(rootPath: string): { ok: boolean; error?: string } {
721
+ if (!rootPath) return { ok: false, error: 'No project path' }
722
+ if (!this.activeTabId) return { ok: false, error: 'No active tab' }
723
+ const tab = this.tabs.get(this.activeTabId)
724
+ if (!tab) return { ok: false, error: 'No active tab' }
725
+ if (!this.ctlsurfApi.getApiKey()) return { ok: false, error: 'No ctlsurf API key configured' }
726
+
727
+ log(`[ctlsurf] Binding tab agent="${tab.agent.name}" to project root_path=${rootPath}`)
728
+ // Remember the binding so project-scoped work (tickets) targets the chosen
729
+ // project instead of the remote tab's local cwd.
730
+ tab.projectRootPath = rootPath
731
+ this.stopNoProjectPolling()
732
+ this.workerWs.disconnect()
733
+ this.connectWorkerWs(tab.agent, rootPath)
734
+ // Tell the renderer the active project changed so the tickets + ctlsurf
735
+ // panels re-query against the bound project (they refresh on this event).
736
+ this.events.onCwdChanged()
737
+ return { ok: true }
738
+ }
739
+
639
740
  connectWorkerWs(agent: AgentConfig, cwd: string): void {
640
741
  const profile = this.getActiveProfile()
641
742
  const apiKey = profile.apiKey || process.env.CTLSURF_API_KEY