@phenx-inc/ctlsurf 0.11.0 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/out/headless/index.mjs +115 -5
  2. package/out/headless/index.mjs.map +3 -3
  3. package/out/main/index.js +316 -8
  4. package/out/preload/index.js +9 -0
  5. package/out/renderer/assets/{cssMode-Bmzy9hKX.js → cssMode-D7r66wvO.js} +3 -3
  6. package/out/renderer/assets/{freemarker2-CDAsExTa.js → freemarker2-jSWJIjPG.js} +1 -1
  7. package/out/renderer/assets/{handlebars-CvpBYG1L.js → handlebars-BnFyz5RH.js} +1 -1
  8. package/out/renderer/assets/{html-CNhEQqr8.js → html-Bl5UFavp.js} +1 -1
  9. package/out/renderer/assets/{htmlMode-DD7YPgga.js → htmlMode-D4VByBT6.js} +3 -3
  10. package/out/renderer/assets/{index-CGHeDwZa.css → index-Cc_TqOCL.css} +318 -0
  11. package/out/renderer/assets/{index-BDlCPzCG.js → index-DxhW1emk.js} +558 -85
  12. package/out/renderer/assets/{javascript-CsGLsswK.js → javascript-CMN1OmwG.js} +2 -2
  13. package/out/renderer/assets/{jsonMode-CTuGgRvC.js → jsonMode-CJ5AYSPV.js} +3 -3
  14. package/out/renderer/assets/{liquid-BrrN4uul.js → liquid-BGMIDTEm.js} +1 -1
  15. package/out/renderer/assets/{lspLanguageFeatures-Bhdn2Eqy.js → lspLanguageFeatures-DGifVYB5.js} +1 -1
  16. package/out/renderer/assets/{mdx-DJo-gax4.js → mdx-B2Z7R10Q.js} +1 -1
  17. package/out/renderer/assets/{python-DYHeg5b2.js → python-LMDMSSdn.js} +1 -1
  18. package/out/renderer/assets/{razor-Cqcks14K.js → razor-BZSrziPu.js} +1 -1
  19. package/out/renderer/assets/{tsMode-YTLgCayh.js → tsMode-DF9m6_3n.js} +1 -1
  20. package/out/renderer/assets/{typescript-oxBn_Vcw.js → typescript-rZeCMf0a.js} +1 -1
  21. package/out/renderer/assets/{xml-BEqGMCTc.js → xml-fNSZ-7so.js} +1 -1
  22. package/out/renderer/assets/{yaml-BGSrk5HC.js → yaml-1wxIuvzh.js} +1 -1
  23. package/out/renderer/index.html +2 -2
  24. package/package.json +1 -1
  25. package/src/main/agents.ts +9 -1
  26. package/src/main/ctlsurfApi.ts +7 -0
  27. package/src/main/index.ts +62 -4
  28. package/src/main/orchestrator.ts +112 -4
  29. package/src/main/ssh.ts +348 -0
  30. package/src/preload/index.ts +26 -0
  31. package/src/renderer/App.tsx +25 -1
  32. package/src/renderer/components/AgentPicker.tsx +18 -0
  33. package/src/renderer/components/ProjectPicker.tsx +121 -0
  34. package/src/renderer/components/RemoteConnect.tsx +315 -0
  35. package/src/renderer/components/SpeakControls.tsx +8 -2
  36. package/src/renderer/components/TerminalPanel.tsx +134 -23
  37. package/src/renderer/components/TicketPanel.tsx +67 -26
  38. package/src/renderer/styles.css +318 -0
@@ -1,5 +1,5 @@
1
- import { conf as conf$1, language as language$1 } from "./typescript-oxBn_Vcw.js";
2
- import "./index-BDlCPzCG.js";
1
+ import { conf as conf$1, language as language$1 } from "./typescript-rZeCMf0a.js";
2
+ import "./index-DxhW1emk.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-BDlCPzCG.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-Bhdn2Eqy.js";
3
- import { a, D, h, R, c, i, j, t, k } from "./lspLanguageFeatures-Bhdn2Eqy.js";
1
+ import { c as createWebWorker, l as languages, e as editor } from "./index-DxhW1emk.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-DGifVYB5.js";
3
+ import { a, D, h, R, c, i, j, t, k } from "./lspLanguageFeatures-DGifVYB5.js";
4
4
  const STOP_WHEN_IDLE_FOR = 2 * 60 * 1e3;
5
5
  class WorkerManager {
6
6
  constructor(defaults) {
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-BDlCPzCG.js";
1
+ import { l as languages } from "./index-DxhW1emk.js";
2
2
  const EMPTY_ELEMENTS = [
3
3
  "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-BDlCPzCG.js";
1
+ import { R as Range$1, l as languages, e as editor, U as Uri, M as MarkerSeverity } from "./index-DxhW1emk.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-BDlCPzCG.js";
1
+ import { l as languages } from "./index-DxhW1emk.js";
2
2
  const conf = {
3
3
  comments: {
4
4
  blockComment: ["{/*", "*/}"]
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-BDlCPzCG.js";
1
+ import { l as languages } from "./index-DxhW1emk.js";
2
2
  const conf = {
3
3
  comments: {
4
4
  lineComment: "#",
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-BDlCPzCG.js";
1
+ import { l as languages } from "./index-DxhW1emk.js";
2
2
  const EMPTY_ELEMENTS = [
3
3
  "area",
4
4
  "base",
@@ -1,4 +1,4 @@
1
- import { 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-BDlCPzCG.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-DxhW1emk.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-BDlCPzCG.js";
1
+ import { l as languages } from "./index-DxhW1emk.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-BDlCPzCG.js";
1
+ import { l as languages } from "./index-DxhW1emk.js";
2
2
  const conf = {
3
3
  comments: {
4
4
  blockComment: ["<!--", "-->"]
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-BDlCPzCG.js";
1
+ import { l as languages } from "./index-DxhW1emk.js";
2
2
  const 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-BDlCPzCG.js"></script>
53
- <link rel="stylesheet" crossorigin href="./assets/index-CGHeDwZa.css">
52
+ <script type="module" crossorigin src="./assets/index-DxhW1emk.js"></script>
53
+ <link rel="stylesheet" crossorigin href="./assets/index-Cc_TqOCL.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.11.0",
3
+ "version": "0.12.1",
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
 
@@ -201,7 +204,7 @@ function stopAttention(): void {
201
204
  // ─── Orchestrator (shared logic) ──────────────────
202
205
 
203
206
  const orchestrator = new Orchestrator(
204
- getSettingsDir(true),
207
+ SETTINGS_DIR,
205
208
  {
206
209
  onPtyData: (tabId, data) => mainWindow?.webContents.send('pty:data', tabId, data),
207
210
  onPtyExit: (tabId, code) => mainWindow?.webContents.send('pty:exit', tabId, code),
@@ -217,7 +220,10 @@ const orchestrator = new Orchestrator(
217
220
  },
218
221
  onCwdChanged: () => {
219
222
  mainWindow?.webContents.send('app:cwdChanged')
220
- updateProjectBadge(orchestrator.cwd)
223
+ // Reflect the active tab's bound project (remote SSH sessions bind to a
224
+ // chosen ctlsurf project whose root_path is meaningful), falling back to
225
+ // the local launch cwd for unbound/local tabs.
226
+ updateProjectBadge(orchestrator.getActiveTabProjectPath() || orchestrator.cwd)
221
227
  },
222
228
  }
223
229
  )
@@ -298,6 +304,56 @@ ipcMain.handle('pty:setActiveTab', (_event, tabId: string) => {
298
304
  ipcMain.handle('agents:list', () => getAvailableAgents())
299
305
  ipcMain.handle('agents:default', () => getDefaultAgent())
300
306
 
307
+ // ─── Remote (SSH) ─────────────────────────────────
308
+
309
+ // Hosts to offer: recently-used targets first, then ~/.ssh/config aliases.
310
+ ipcMain.handle('ssh:listHosts', () => ({
311
+ configHosts: listSshHosts(),
312
+ recents: getRecentHosts(SETTINGS_DIR),
313
+ }))
314
+
315
+ // Non-interactive reachability + tmux probe (key-auth hosts only).
316
+ ipcMain.handle('ssh:probe', (_event, target: string) => probeRemote(target))
317
+
318
+ // Build the ssh-backed AgentConfig for a chosen connect action.
319
+ ipcMain.handle('ssh:buildAgent', (_event, target: string, action: RemoteAction, agentCmd?: string) =>
320
+ buildRemoteAgent({ target, action, agentCmd }),
321
+ )
322
+
323
+ // Remember a target as most-recently-used (no secrets stored — just the host).
324
+ ipcMain.handle('ssh:addRecent', (_event, target: string) => {
325
+ addRecentHost(SETTINGS_DIR, target)
326
+ return { ok: true }
327
+ })
328
+
329
+ // Stash an in-app password for the next remote spawn to `target`. Held only in
330
+ // memory (auto-expires), injected once into ssh's prompt, then dropped.
331
+ ipcMain.handle('ssh:setPassword', (_event, target: string, password: string) => {
332
+ orchestrator.setPendingSshPassword(target, password)
333
+ return { ok: true }
334
+ })
335
+
336
+ // ─── ctlsurf project selection (for remote sessions) ──
337
+
338
+ // List existing ctlsurf projects (folders) to pick from.
339
+ ipcMain.handle('ctlsurf:listProjects', async () => {
340
+ if (!orchestrator.ctlsurfApi.getApiKey()) return { ok: false, projects: [], error: 'No ctlsurf API key configured' }
341
+ try {
342
+ const folders = await orchestrator.ctlsurfApi.listFolders()
343
+ const projects = folders
344
+ .filter((f) => f && f.id && f.root_path)
345
+ .map((f) => ({ id: f.id, name: f.name || '(unnamed)', root_path: f.root_path as string }))
346
+ return { ok: true, projects }
347
+ } catch (err: any) {
348
+ return { ok: false, projects: [], error: err?.message || 'Failed to list projects' }
349
+ }
350
+ })
351
+
352
+ // Bind the active (remote) tab to a chosen project by its root_path.
353
+ ipcMain.handle('ctlsurf:bindProject', (_event, rootPath: string) =>
354
+ orchestrator.bindActiveTabToProject(rootPath),
355
+ )
356
+
301
357
  ipcMain.handle('app:homePath', () => app.getPath('home'))
302
358
  ipcMain.handle('app:cwd', () => process.env.CTLSURF_WORKER_CWD || process.cwd())
303
359
  ipcMain.handle('app:projectName', () => orchestrator.projectName)
@@ -442,7 +498,8 @@ ipcMain.handle('worker:getWebviewInfo', async () => {
442
498
  try {
443
499
  const { code } = await orchestrator.ctlsurfApi.getAuthCode()
444
500
  let pageUrl = `${frontendUrl}?embed=1&_code=${encodeURIComponent(code)}`
445
- const currentCwd = orchestrator.cwd
501
+ // Prefer the active tab's bound project (remote sessions), else the local cwd.
502
+ const currentCwd = orchestrator.getActiveTabProjectPath() || orchestrator.cwd
446
503
 
447
504
  if (currentCwd) {
448
505
  try {
@@ -548,7 +605,8 @@ ipcMain.handle('tracking:set', async (_event, enabled: boolean) => {
548
605
  // ─── Tickets IPC ──────────────────────────────────
549
606
 
550
607
  ipcMain.handle('tickets:project', () => {
551
- const cwd = orchestrator.getActiveTabCwd()
608
+ // Use the bound project path for remote sessions, else the local cwd.
609
+ const cwd = orchestrator.getActiveTabProjectPath()
552
610
  return { cwd, name: cwd ? cwd.split('/').filter(Boolean).pop() || cwd : null }
553
611
  })
554
612
  ipcMain.handle('tickets:add', async (_event, input: {
@@ -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)
@@ -562,12 +627,19 @@ export class Orchestrator {
562
627
  }
563
628
 
564
629
  setActiveTab(tabId: string): void {
630
+ const prevProjectPath = this.getActiveTabProjectPath()
565
631
  this.activeTabId = tabId
566
632
  const tab = this.tabs.get(tabId)
567
633
  if (tab) {
568
634
  this.currentAgent = tab.agent
569
635
  this.currentCwd = tab.cwd
570
636
  }
637
+ // Switching tabs changes which project the dock badge / window title and
638
+ // the renderer's ticket/ctlsurf panels should reflect. Only signal when the
639
+ // effective project path actually changed to avoid needless renderer churn.
640
+ if (this.getActiveTabProjectPath() !== prevProjectPath) {
641
+ this.events.onCwdChanged()
642
+ }
571
643
  }
572
644
 
573
645
  getTabIds(): string[] {
@@ -607,8 +679,19 @@ export class Orchestrator {
607
679
  return this.tabs.get(this.activeTabId)?.cwd ?? null
608
680
  }
609
681
 
682
+ /**
683
+ * Path used to resolve the active tab's ctlsurf project. For a remote session
684
+ * bound to a project this is the project's root_path; otherwise the local cwd.
685
+ */
686
+ getActiveTabProjectPath(): string | null {
687
+ if (!this.activeTabId) return null
688
+ const tab = this.tabs.get(this.activeTabId)
689
+ if (!tab) return null
690
+ return tab.projectRootPath ?? tab.cwd
691
+ }
692
+
610
693
  async addTicketForActiveTab(input: TicketInput): Promise<{ ok: boolean; error?: string }> {
611
- const cwd = this.getActiveTabCwd()
694
+ const cwd = this.getActiveTabProjectPath()
612
695
  if (!cwd) return { ok: false, error: 'No active terminal tab' }
613
696
  if (!this.ctlsurfApi.getApiKey()) {
614
697
  return { ok: false, error: 'ctlsurf API key not configured' }
@@ -617,7 +700,7 @@ export class Orchestrator {
617
700
  }
618
701
 
619
702
  async updateTicketForActiveTab(rowId: string, input: TicketInput): Promise<{ ok: boolean; error?: string }> {
620
- const cwd = this.getActiveTabCwd()
703
+ const cwd = this.getActiveTabProjectPath()
621
704
  if (!cwd) return { ok: false, error: 'No active terminal tab' }
622
705
  if (!this.ctlsurfApi.getApiKey()) {
623
706
  return { ok: false, error: 'ctlsurf API key not configured' }
@@ -626,7 +709,7 @@ export class Orchestrator {
626
709
  }
627
710
 
628
711
  async listTicketsForActiveTab(): Promise<{ ok: boolean; tickets: import('./ticketStore').Ticket[]; error?: string }> {
629
- const cwd = this.getActiveTabCwd()
712
+ const cwd = this.getActiveTabProjectPath()
630
713
  if (!cwd) return { ok: false, tickets: [], error: 'No active terminal tab' }
631
714
  if (!this.ctlsurfApi.getApiKey()) {
632
715
  return { ok: false, tickets: [], error: 'ctlsurf API key not configured' }
@@ -636,6 +719,31 @@ export class Orchestrator {
636
719
 
637
720
  // ─── Worker WebSocket ───────────────────────────
638
721
 
722
+ // Bind the active tab (typically a remote SSH session, whose local cwd is
723
+ // meaningless) to a chosen ctlsurf project. We register the worker WS using
724
+ // the project's root_path so the backend resolves that folder — the same
725
+ // mechanism create-project relies on. This lights up chat logging, tickets,
726
+ // remote-control, and the project header/panel for the remote session.
727
+ bindActiveTabToProject(rootPath: string): { ok: boolean; error?: string } {
728
+ if (!rootPath) return { ok: false, error: 'No project path' }
729
+ if (!this.activeTabId) return { ok: false, error: 'No active tab' }
730
+ const tab = this.tabs.get(this.activeTabId)
731
+ if (!tab) return { ok: false, error: 'No active tab' }
732
+ if (!this.ctlsurfApi.getApiKey()) return { ok: false, error: 'No ctlsurf API key configured' }
733
+
734
+ log(`[ctlsurf] Binding tab agent="${tab.agent.name}" to project root_path=${rootPath}`)
735
+ // Remember the binding so project-scoped work (tickets) targets the chosen
736
+ // project instead of the remote tab's local cwd.
737
+ tab.projectRootPath = rootPath
738
+ this.stopNoProjectPolling()
739
+ this.workerWs.disconnect()
740
+ this.connectWorkerWs(tab.agent, rootPath)
741
+ // Tell the renderer the active project changed so the tickets + ctlsurf
742
+ // panels re-query against the bound project (they refresh on this event).
743
+ this.events.onCwdChanged()
744
+ return { ok: true }
745
+ }
746
+
639
747
  connectWorkerWs(agent: AgentConfig, cwd: string): void {
640
748
  const profile = this.getActiveProfile()
641
749
  const apiKey = profile.apiKey || process.env.CTLSURF_API_KEY